Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ var ExportCmd = &cobra.Command{
// so that the authn middleware extracts the session and stores in the context. (which the db can use to authorize queries)
authz := auth.Authorizer{Authz: nil}

db, err := database.NewPostgreSQL(ctx, cfg.DatabaseURL, authz)
db, err := database.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, 0)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ var ImportCmd = &cobra.Command{
// so that the authn middleware extracts the session and stores in the context. (which the db can use to authorize queries)
authz := auth.Authorizer{Authz: nil}

db, err := database.NewPostgreSQL(ctx, cfg.DatabaseURL, authz)
db, err := database.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, 0)
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This CLI path passes embeddingDimensions=0, which skips creating the semantic_embedding vector columns. If --generate-embeddings is enabled, the importer later calls UpsertServerEmbedding/SetServerEmbedding which updates semantic_embedding and will fail on a fresh schema (the vector column is no longer created in the initial migration). Consider passing cfg.Embeddings.Dimensions when embeddings generation is requested (or otherwise ensuring ensureVectorDimensions runs before storing embeddings).

Suggested change
db, err := database.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, 0)
embeddingDimensions := 0
if importGenerateEmbeddings {
embeddingDimensions = cfg.Embeddings.Dimensions
}
db, err := database.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, embeddingDimensions)

Copilot uses AI. Check for mistakes.
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
Expand Down
3 changes: 1 addition & 2 deletions internal/registry/api/handlers/v0/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service.
}, nil
})

var tags []string
tags = []string{"servers"}
var tags = []string{"servers"}

// List servers endpoint
huma.Register(api, huma.Operation{
Expand Down
13 changes: 1 addition & 12 deletions internal/registry/api/handlers/v0/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"github.com/agentregistry-dev/agentregistry/pkg/registry/database"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/jackc/pgx/v5"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -136,8 +135,7 @@ func TestListServersEndpoint(t *testing.T) {

func TestListServersSemanticSearch(t *testing.T) {
ctx := context.Background()
db := internaldb.NewTestDB(t)
ensureVectorExtension(t, db)
db := internaldb.NewTestDBWithEmbeddings(t, 3)

cfg := config.NewConfig()
cfg.Embeddings.Enabled = true
Expand Down Expand Up @@ -436,15 +434,6 @@ func TestGetServerVersionEndpoint(t *testing.T) {
}
}

func ensureVectorExtension(t *testing.T, db database.Database) {
t.Helper()
err := db.InTransaction(context.Background(), func(ctx context.Context, tx pgx.Tx) error {
_, execErr := tx.Exec(ctx, "CREATE EXTENSION IF NOT EXISTS vector")
return execErr
})
require.NoError(t, err, "failed to ensure pgvector extension for tests")
}

type stubEmbeddingProvider struct {
mu sync.Mutex
vectors map[string][]float32
Expand Down
5 changes: 0 additions & 5 deletions internal/registry/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package config
import "fmt"

// Validate performs runtime validations on the loaded configuration.
// It is intentionally strict for embeddings to avoid runtime pgvector errors.
func Validate(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
Expand All @@ -12,10 +11,6 @@ func Validate(cfg *Config) error {
if cfg.Embeddings.Dimensions <= 0 {
return fmt.Errorf("embeddings dimensions must be positive (got %d)", cfg.Embeddings.Dimensions)
}
// Database schema currently provisions vector(1536). Reject mismatches early.
if cfg.Embeddings.Dimensions != 1536 {
return fmt.Errorf("embeddings dimensions must equal 1536 to match database schema (got %d)", cfg.Embeddings.Dimensions)
}
if cfg.Embeddings.Model == "" {
return fmt.Errorf("embeddings model must be specified when embeddings are enabled")
}
Expand Down
18 changes: 6 additions & 12 deletions internal/registry/database/migrations/001_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ CREATE TABLE servers (
published_date TIMESTAMP WITH TIME ZONE,
unpublished_date TIMESTAMP WITH TIME ZONE,

-- Semantic embedding columns for vector search
semantic_embedding vector(1536),
-- Semantic embedding metadata (the vector column itself is created by
-- ensureVectorDimensions at startup, sized to EMBEDDINGS_DIMENSIONS)
semantic_embedding_provider TEXT,
semantic_embedding_model TEXT,
semantic_embedding_dimensions INTEGER,
semantic_embedding_checksum TEXT,
semantic_embedding_generated_at TIMESTAMPTZ,

-- Primary key
CONSTRAINT servers_pkey PRIMARY KEY (server_name, version)
);
Expand All @@ -65,9 +65,6 @@ CREATE UNIQUE INDEX idx_unique_latest_per_server ON servers (server_name) WHERE
CREATE INDEX idx_servers_json_remotes ON servers USING GIN((value->'remotes'));
CREATE INDEX idx_servers_json_packages ON servers USING GIN((value->'packages'));

-- HNSW index for semantic embedding similarity search
CREATE INDEX idx_servers_semantic_embedding_hnsw ON servers USING hnsw (semantic_embedding vector_cosine_ops);

-- Check constraints for servers
ALTER TABLE servers ADD CONSTRAINT check_status_valid
CHECK (status IN ('active', 'deprecated', 'deleted'));
Expand Down Expand Up @@ -181,14 +178,14 @@ CREATE TABLE agents (
published_date TIMESTAMP WITH TIME ZONE,
unpublished_date TIMESTAMP WITH TIME ZONE,

-- Semantic embedding columns for vector search
semantic_embedding vector(1536),
-- Semantic embedding metadata (the vector column itself is created by
-- ensureVectorDimensions at startup, sized to EMBEDDINGS_DIMENSIONS)
semantic_embedding_provider TEXT,
semantic_embedding_model TEXT,
semantic_embedding_dimensions INTEGER,
semantic_embedding_checksum TEXT,
semantic_embedding_generated_at TIMESTAMPTZ,

-- Primary key
CONSTRAINT agents_pkey PRIMARY KEY (agent_name, version)
);
Expand All @@ -205,9 +202,6 @@ CREATE INDEX idx_agents_published ON agents (published);
-- Ensure only one version per agent is marked as latest
CREATE UNIQUE INDEX idx_unique_latest_per_agent ON agents (agent_name) WHERE is_latest = true;

-- HNSW index for semantic embedding similarity search
CREATE INDEX idx_agents_semantic_embedding_hnsw ON agents USING hnsw (semantic_embedding vector_cosine_ops);

-- Trigger function to auto-update updated_at
CREATE OR REPLACE FUNCTION update_agents_updated_at()
RETURNS TRIGGER AS $$
Expand Down
125 changes: 123 additions & 2 deletions internal/registry/database/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ func (db *PostgreSQL) getExecutor(tx pgx.Tx) Executor {
return db.pool
}

// NewPostgreSQL creates a new instance of the PostgreSQL database
func NewPostgreSQL(ctx context.Context, connectionURI string, authz auth.Authorizer) (*PostgreSQL, error) {
// NewPostgreSQL creates a new instance of the PostgreSQL database.
// embeddingDimensions configures the pgvector column size for semantic search.
// Pass 0 to skip dimension reconciliation (e.g., when embeddings are disabled).
func NewPostgreSQL(ctx context.Context, connectionURI string, authz auth.Authorizer, embeddingDimensions int) (*PostgreSQL, error) {
// Parse connection config for pool settings
config, err := pgxpool.ParseConfig(connectionURI)
if err != nil {
Expand Down Expand Up @@ -81,12 +83,131 @@ func NewPostgreSQL(ctx context.Context, connectionURI string, authz auth.Authori
return nil, fmt.Errorf("failed to run database migrations: %w", err)
}

// After migrations, ensure the semantic_embedding vector columns exist with
// the configured dimension. The initial schema deliberately omits these
// columns so the dimension is never hardcoded in SQL. This function creates
// them on first run and reconciles the dimension if it changes later.
if embeddingDimensions > 0 {
if err := ensureVectorDimensions(ctx, conn.Conn(), embeddingDimensions); err != nil {
return nil, fmt.Errorf("failed to reconcile vector dimensions: %w", err)
}
}
Comment on lines +86 to +94
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new startup reconciliation path (ensureVectorDimensions invoked from NewPostgreSQL) introduces non-trivial schema mutation behavior (create vs alter, index drop/recreate, clearing embeddings). There don’t appear to be database tests covering these branches yet; adding integration tests for (1) fresh DB creates columns/indexes, (2) dimension change alters both tables and recreates indexes, and (3) no-op when already correct would reduce regression risk.

Copilot uses AI. Check for mistakes.

return &PostgreSQL{
pool: pool,
authz: authz,
}, nil
}

// ensureVectorDimensions creates or reconciles the semantic_embedding vector
// columns to match the configured EMBEDDINGS_DIMENSIONS. The initial SQL
// migration deliberately omits these columns so the dimension is never
// hardcoded in SQL. This function:
// - Creates the columns + HNSW indexes on first startup (fresh install)
// - Alters the columns if the configured dimension changes (provider switch)
// - No-ops if the columns already exist with the correct dimension
func ensureVectorDimensions(ctx context.Context, conn *pgx.Conn, dimensions int) error {
// Check whether the column exists and, if so, its current dimension.
// pgvector stores dimension+4 in atttypmod (the 4 accounts for the typmod header).
var currentDim int
columnExists := true
err := conn.QueryRow(ctx, `
SELECT a.atttypmod - 4
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'public'
AND c.relname = 'servers'
AND a.attname = 'semantic_embedding'
AND a.atttypmod > 0
`).Scan(&currentDim)
if err != nil {
// Column doesn't exist yet (fresh install or pre-embeddings schema).
columnExists = false
}

if columnExists && currentDim == dimensions {
return nil // Already correct, nothing to do.
Comment on lines +122 to +130
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureVectorDimensions treats any error from the pg_attribute query as “column doesn’t exist”. That can mask real failures (e.g., permission issues) and then attempts to ADD COLUMN, which may fail or do the wrong thing. Consider handling pgx.ErrNoRows as the “doesn’t exist” case and returning the error for anything else (and also handle the case where the column exists but has no typmod / atttypmod <= 0).

Suggested change
AND a.atttypmod > 0
`).Scan(&currentDim)
if err != nil {
// Column doesn't exist yet (fresh install or pre-embeddings schema).
columnExists = false
}
if columnExists && currentDim == dimensions {
return nil // Already correct, nothing to do.
`).Scan(&currentDim)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Column doesn't exist yet (fresh install or pre-embeddings schema).
columnExists = false
} else {
return fmt.Errorf("failed to query semantic_embedding column: %w", err)
}
}
if columnExists {
// Column exists but has no valid typmod / dimension; this is an unexpected state
// that we can't safely reconcile automatically.
if currentDim <= 0 {
return fmt.Errorf("semantic_embedding column exists but has no valid dimension typmod")
}
if currentDim == dimensions {
return nil // Already correct, nothing to do.
}

Copilot uses AI. Check for mistakes.
}

tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Printf("Failed to rollback dimension migration: %v", err)
}
}()

for _, table := range []string{"servers", "agents"} {
if err := reconcileTableEmbedding(ctx, tx, table, columnExists, currentDim, dimensions); err != nil {
return err
}
}
Comment on lines +129 to +147
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existence/dimension detection is only done against public.servers.semantic_embedding, but the result is applied to both servers and agents. If the two tables ever get out of sync (e.g., one has the column and the other doesn’t, or dimensions differ), this can either no-op incorrectly or attempt an ALTER on a missing column. Consider checking existence + current dimension per table (or at least verifying both tables match before returning early).

Copilot uses AI. Check for mistakes.

if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit vector dimension setup: %w", err)
}

if columnExists {
log.Printf("Vector dimensions reconciled to %d (embeddings cleared; regeneration required)", dimensions)
} else {
log.Printf("Vector columns created with %d dimensions", dimensions)
}
return nil
}

// reconcileTableEmbedding creates or alters the semantic_embedding column for a
// single table and (re)creates its HNSW index.
func reconcileTableEmbedding(ctx context.Context, tx pgx.Tx, table string, columnExists bool, currentDim, dimensions int) error {
idx := fmt.Sprintf("idx_%s_semantic_embedding_hnsw", table)
if columnExists {
return alterEmbeddingColumn(ctx, tx, table, idx, currentDim, dimensions)
}
return addEmbeddingColumn(ctx, tx, table, idx, dimensions)
}

// alterEmbeddingColumn changes an existing semantic_embedding column to a new dimension.
func alterEmbeddingColumn(ctx context.Context, tx pgx.Tx, table, idx string, currentDim, dimensions int) error {
log.Printf("Reconciling %s.semantic_embedding: %d -> %d dimensions", table, currentDim, dimensions)

if _, err := tx.Exec(ctx, fmt.Sprintf("DROP INDEX IF EXISTS %s", idx)); err != nil {
return fmt.Errorf("failed to drop index %s: %w", idx, err)
}
if _, err := tx.Exec(ctx, fmt.Sprintf("UPDATE %s SET semantic_embedding = NULL", table)); err != nil {
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UPDATE %s SET semantic_embedding = NULL will rewrite every row, even when semantic_embedding is already NULL, causing unnecessary bloat and potentially long startup times on large tables. Restrict this to rows that actually have embeddings (e.g., WHERE semantic_embedding IS NOT NULL) or use an ALTER COLUMN ... TYPE ... USING NULL approach to avoid a full-table update step.

Suggested change
if _, err := tx.Exec(ctx, fmt.Sprintf("UPDATE %s SET semantic_embedding = NULL", table)); err != nil {
if _, err := tx.Exec(ctx, fmt.Sprintf("UPDATE %s SET semantic_embedding = NULL WHERE semantic_embedding IS NOT NULL", table)); err != nil {

Copilot uses AI. Check for mistakes.
return fmt.Errorf("failed to clear embeddings in %s: %w", table, err)
}
alter := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN semantic_embedding TYPE vector(%d)", table, dimensions)
if _, err := tx.Exec(ctx, alter); err != nil {
return fmt.Errorf("failed to alter %s.semantic_embedding to vector(%d): %w", table, dimensions, err)
}
return createEmbeddingIndex(ctx, tx, table, idx)
}

// addEmbeddingColumn adds a new semantic_embedding column with the given dimension.
func addEmbeddingColumn(ctx context.Context, tx pgx.Tx, table, idx string, dimensions int) error {
log.Printf("Creating %s.semantic_embedding as vector(%d)", table, dimensions)

addCol := fmt.Sprintf("ALTER TABLE %s ADD COLUMN semantic_embedding vector(%d)", table, dimensions)
if _, err := tx.Exec(ctx, addCol); err != nil {
return fmt.Errorf("failed to add semantic_embedding to %s: %w", table, err)
}
return createEmbeddingIndex(ctx, tx, table, idx)
}

// createEmbeddingIndex creates the HNSW index for semantic embedding similarity search.
func createEmbeddingIndex(ctx context.Context, tx pgx.Tx, table, idx string) error {
createIdx := fmt.Sprintf(
"CREATE INDEX IF NOT EXISTS %s ON %s USING hnsw (semantic_embedding vector_cosine_ops)",
idx, table,
)
if _, err := tx.Exec(ctx, createIdx); err != nil {
return fmt.Errorf("failed to create index %s: %w", idx, err)
}
return nil
}

func (db *PostgreSQL) ListServers(
ctx context.Context,
tx pgx.Tx,
Expand Down
11 changes: 9 additions & 2 deletions internal/registry/database/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func ensureTemplateDB(ctx context.Context, adminConn *pgx.Conn) error {
// Connect to template and run migrations (always) to keep it up-to-date
// Create a permissive authz for tests
testAuthz := createTestAuthz()
templateDB, err := NewPostgreSQL(ctx, templateURI, testAuthz)
templateDB, err := NewPostgreSQL(ctx, templateURI, testAuthz, 0)
if err != nil {
return fmt.Errorf("failed to connect to template database: %w", err)
}
Expand All @@ -132,6 +132,13 @@ func ensureVectorExtension(ctx context.Context, uri string) error {
// The template database has migrations pre-applied, so each test is fast.
// Requires PostgreSQL to be running on localhost:5432 (e.g., via docker-compose).
func NewTestDB(t *testing.T) database.Database {
return NewTestDBWithEmbeddings(t, 0)
}

// NewTestDBWithEmbeddings is like NewTestDB but also creates the semantic_embedding
// vector columns with the given dimension. Use this for tests that exercise
// embedding/semantic-search functionality. Pass 0 to skip (same as NewTestDB).
func NewTestDBWithEmbeddings(t *testing.T, embeddingDimensions int) database.Database {
t.Helper()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
Expand Down Expand Up @@ -180,7 +187,7 @@ func NewTestDB(t *testing.T) database.Database {

// Create a permissive authz for tests
testAuthz := createTestAuthz()
db, err := NewPostgreSQL(ctx, testURI, testAuthz)
db, err := NewPostgreSQL(ctx, testURI, testAuthz, embeddingDimensions)
require.NoError(t, err, "Failed to connect to test database")

// Register cleanup to close connection
Expand Down
2 changes: 1 addition & 1 deletion internal/registry/registry_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func App(_ context.Context, opts ...types.AppOptions) error {
return fmt.Errorf("failed to create database via factory: %w", err)
}
} else {
baseDB, err := internaldb.NewPostgreSQL(ctx, cfg.DatabaseURL, authz)
baseDB, err := internaldb.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, cfg.Embeddings.Dimensions)
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewPostgreSQL expects embeddingDimensions to be 0 when embeddings are disabled (per its docstring), but this call always passes cfg.Embeddings.Dimensions (default 1536 even when Embeddings.Enabled is false). That means fresh installs will always create the vector columns/indexes and could trigger reconciliation even when embeddings are disabled. Consider passing 0 unless cfg.Embeddings.Enabled is true (or introducing an explicit “reconcile vectors” knob).

Suggested change
baseDB, err := internaldb.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, cfg.Embeddings.Dimensions)
embeddingDimensions := 0
if cfg.Embeddings.Enabled {
embeddingDimensions = cfg.Embeddings.Dimensions
}
baseDB, err := internaldb.NewPostgreSQL(ctx, cfg.DatabaseURL, authz, embeddingDimensions)

Copilot uses AI. Check for mistakes.
if err != nil {
return fmt.Errorf("failed to connect to PostgreSQL: %w", err)
}
Expand Down
Loading