Skip to content
Merged
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
32 changes: 32 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,38 @@ index instead of silently recording success. Other populated installations
should also prebuild the index concurrently to avoid blocking writes during
startup. No application restart is needed for the operational index build.

### Namespace suffix index

Migration 016 adds `idx_servers_namespace_suffix` for substring searches that
span the namespace/path separator. The existing `check_server_name_format`
constraint guarantees exactly one `/`. A matching search must therefore match
a suffix of the namespace; reversing it converts that suffix to a B-tree prefix
lookup. The application still applies the complete original substring predicate.
Searches without a namespace component keep their existing behavior.

For a busy existing database, use the same concurrent-build procedure and
monitoring described above, with these statements in an autocommit session:

```sql
SET application_name = 'registry-namespace-index-build';
SET lock_timeout = '10s';
SET statement_timeout = '10min';
CREATE INDEX CONCURRENTLY idx_servers_namespace_suffix
ON public.servers (lower(reverse(split_part(server_name, '/', 1))) text_pattern_ops);
```

Verify both `indisvalid` and `indisready`, and the exact expression/operator class
using `pg_get_indexdef`, before deploying the application migration. A failed
build must be repaired explicitly; `IF NOT EXISTS` cannot repair it. Keep the
trigram index: searches without a namespace still use it. After creation, run
`ANALYZE public.servers` to collect expression statistics, then compare plans for
rare namespaces, partial namespace suffixes, common terms, and latest/all-version
searches. Measure publisher latency as well as read performance.

The local constrained benchmark and its limits are documented in
[`tools/readbench/CONSTRAINED_RESULTS.md`](../tools/readbench/CONSTRAINED_RESULTS.md).
That experiment does not deploy this index or change production settings.

## Structure

```
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/api/official-registry-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ The official registry extends the `GET /v0.1/servers` endpoint with additional q

- `updated_since` - Filter servers updated after RFC3339 timestamp (e.g., `2025-08-07T13:15:04.280Z`)
- `search` - Case-insensitive substring search on server names (e.g., `filesystem`)
- A nonempty search must contain at least three Unicode characters after trimming surrounding whitespace. Shorter or whitespace-only searches return HTTP `422` before database work. Surrounding whitespace is ignored only for this length check; substring matching remains unchanged.
- Omitted or empty `search` retains normal listing behavior. Invalid short searches are never treated as unfiltered listings. This restriction changes previously accepted one- and two-character searches.
- This is intentionally simple. For more advanced searching and filtering, use a subregistry.
- `version` - Filter by version (`latest` for latest versions only, or an exact version such as `1.2.3`)
- `include_deleted` - Include deleted servers in results (default: `false`, but automatically `true` when `updated_since` is provided for incremental sync)
Expand All @@ -53,6 +55,8 @@ To retrieve only the latest version of each matching server, pass `version=lates

Example: `GET /v0.1/servers?search=filesystem&version=latest`

When the complete server name is known, use `GET /v0.1/servers/{url-encoded-server-name}/versions/latest` (or a specific version) instead of substring search. For example: `GET /v0.1/servers/io.github.example%2Fweather/versions/latest`.

Example: `GET /v0.1/servers?search=filesystem&updated_since=2025-08-01T00:00:00Z&version=latest`

### Server Detail
Expand Down
64 changes: 64 additions & 0 deletions internal/api/handlers/v0/search_minimum_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package v0_test

import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/service"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/stretchr/testify/require"
)

type searchRecordingService struct {
service.RegistryService
calls int
search *string
}

func (s *searchRecordingService) ListServers(_ context.Context, filter *database.ServerFilter, _ string, _ int) ([]*apiv0.ServerResponse, string, error) {
s.calls++
s.search = filter.SubstringName
return []*apiv0.ServerResponse{}, "", nil
}

func TestSearchMinimumBeforeService(t *testing.T) {
for _, prefix := range []string{"/v0", "/v0.1"} {
for _, tc := range []struct {
search string
status int
}{
{"", http.StatusOK}, {"a", http.StatusUnprocessableEntity}, {"ai", http.StatusUnprocessableEntity},
{" a ", http.StatusUnprocessableEntity}, {" \t ", http.StatusUnprocessableEntity},
{"测试", http.StatusUnprocessableEntity}, {"测试名", http.StatusOK},
{"mcp", http.StatusOK}, {" abc ", http.StatusOK}, {"a.b", http.StatusOK},
} {
t.Run(prefix+"/"+tc.search, func(t *testing.T) {
s := &searchRecordingService{}
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("search minimum", "1.0.0"))
v0.RegisterServersEndpoints(api, prefix, s)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, prefix+"/servers?search="+url.QueryEscape(tc.search), nil))
require.Equal(t, tc.status, w.Code, w.Body.String())
if tc.status != http.StatusOK {
require.Zero(t, s.calls, "invalid search must not reach the service/database")
require.Contains(t, w.Body.String(), "at least 3 characters")
} else {
require.Equal(t, 1, s.calls)
if tc.search == "" {
require.Nil(t, s.search)
} else {
require.Equal(t, tc.search, *s.search)
}
}
})
}
}
}
21 changes: 17 additions & 4 deletions internal/api/handlers/v0/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"reflect"
"strings"
"time"
"unicode/utf8"

"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/database"
Expand Down Expand Up @@ -53,12 +54,25 @@ func resolveIncludeDeleted(includeDeleted OptionalBool, hasUpdatedSince bool) (b
return includeDeleted.Value, nil
}

// applySearchFilter rejects invalid terms before service/database work. Empty search
// retains listing behavior, and valid terms retain their original matching semantics.
func applySearchFilter(filter *database.ServerFilter, search string) error {
if search == "" {
return nil
}
if utf8.RuneCountInString(strings.TrimSpace(search)) < 3 {
return huma.Error422UnprocessableEntity("search must contain at least 3 characters after trimming surrounding whitespace")
}
filter.SubstringName = &search
return nil
}

// ListServersInput represents the input for listing servers
type ListServersInput struct {
Cursor string `query:"cursor" doc:"Pagination cursor" required:"false" example:"server-cursor-123"`
Limit int `query:"limit" doc:"Number of items per page" default:"30" minimum:"1" maximum:"100" example:"50"`
UpdatedSince string `query:"updated_since" doc:"Filter servers updated since timestamp (RFC3339 datetime)" required:"false" example:"2025-08-07T13:15:04.280Z"`
Search string `query:"search" doc:"Search servers by name (substring match)" required:"false" example:"filesystem"`
Search string `query:"search" doc:"Search servers by name (substring match); nonempty searches require at least 3 characters after trimming surrounding whitespace" required:"false" example:"filesystem"`
Version string `query:"version" doc:"Filter by version ('latest' for latest version, or an exact version like '1.2.3')" required:"false" example:"latest"`
IncludeDeleted OptionalBool `query:"include_deleted" doc:"Include deleted servers in results (default: false, but always true when updated_since is provided)" required:"false"`
}
Expand Down Expand Up @@ -107,9 +121,8 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service.
}
}

// Handle search parameter
if input.Search != "" {
filter.SubstringName = &input.Search
if err := applySearchFilter(filter, input.Search); err != nil {
return nil, err
}

// Handle version parameter
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handlers/v0/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ func TestServersEndpointEdgeCases(t *testing.T) {
{"future updated_since", "?updated_since=2030-01-01T00:00:00Z", http.StatusOK, ""},
{"very old updated_since", "?updated_since=1990-01-01T00:00:00Z", http.StatusOK, ""},
{"empty search parameter", "?search=", http.StatusOK, ""},
{"search with special characters", "?search=测试", http.StatusOK, ""},
{"search with special characters", "?search=测试名", http.StatusOK, ""},
{"combined valid parameters", "?search=server&limit=5&version=latest", http.StatusOK, ""},
}

Expand Down
21 changes: 20 additions & 1 deletion internal/api/handlers/v0/ui_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ <h1 class="text-4xl font-bold text-gray-900 mb-2">Official MCP Registry</h1>
<input
type="search"
id="search"
placeholder="Search servers by name..."
placeholder="Search servers by name (at least 3 characters)..."
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
autofocus
>
Expand Down Expand Up @@ -146,6 +146,7 @@ <h3 class="text-lg font-semibold mb-4">API Base URL</h3>
let currentCursor = null;
let nextCursor = null;
let searchTimeout = null;
let serverRequestSequence = 0;
const RECENT_PREVIEW_COUNT = 6;
let hasRecentServers = false;
let recentExpanded = false;
Expand Down Expand Up @@ -337,7 +338,22 @@ <h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0 break-all">${esc

// Fetch servers
async function fetchServers(cursor = null, search = '', latestOnly = null) {
const requestSequence = ++serverRequestSequence;
updateRecentlyUpdatedVisibility(search, cursor);
document.getElementById('error').classList.add('hidden');
if (search !== '' && Array.from(search.trim()).length < 3) {
servers = [];
cursorHistory = [];
currentCursor = null;
nextCursor = null;
document.getElementById('server-cards').textContent = 'Type at least 3 characters to search.';
updatePagination();
document.getElementById('page-info').textContent = '';
updateURL();
document.getElementById('loading').classList.add('hidden');
document.getElementById('server-list').classList.remove('hidden');
return;
}
try {
if (latestOnly === null) {
latestOnly = document.getElementById('latest-only').checked;
Expand All @@ -350,6 +366,8 @@ <h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0 break-all">${esc

const response = await fetch(url);
const data = await response.json();
if (requestSequence !== serverRequestSequence) return;
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);

servers = data.servers || [];
nextCursor = data.metadata?.nextCursor || null;
Expand All @@ -361,6 +379,7 @@ <h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0 break-all">${esc
document.getElementById('loading').classList.add('hidden');
document.getElementById('server-list').classList.remove('hidden');
} catch (err) {
if (requestSequence !== serverRequestSequence) return;
showError('Failed to load servers: ' + err.message);
}
}
Expand Down
22 changes: 22 additions & 0 deletions internal/database/migrations/016_add_namespace_suffix_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- A substring search spanning '/' must end at the namespace boundary.
-- Reversing the namespace makes that suffix an indexable prefix. Keep the
-- original ILIKE predicate as the final check of the complete search term.
--
-- Build this index CONCURRENTLY before deploying to an existing busy database;
-- see deploy/README.md. The migration runner itself uses a transaction.
SET LOCAL lock_timeout = '5s';

CREATE INDEX IF NOT EXISTS idx_servers_namespace_suffix
ON servers (lower(reverse(split_part(server_name, '/', 1))) text_pattern_ops);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_index
WHERE indexrelid = 'idx_servers_namespace_suffix'::regclass
AND indisvalid AND indisready
) THEN
RAISE EXCEPTION 'idx_servers_namespace_suffix is not valid; repair the concurrent build before retrying migration 016';
END IF;
END
$$;
13 changes: 13 additions & 0 deletions internal/database/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ func buildFilterConditions(filter *ServerFilter, argIndex int) ([]string, []any,
conditions = append(conditions, fmt.Sprintf("server_name ILIKE $%d ESCAPE '\\'", argIndex))
args = append(args, "%"+escaped+"%")
argIndex++
// check_server_name_format guarantees one namespace/path separator. A search
// spanning it must match a suffix of the namespace. This narrows full
// name searches before the original substring predicate is rechecked.
if namespace, _, ok := strings.Cut(*filter.SubstringName, "/"); ok && len(namespace) >= 3 {
reversed := []rune(namespace)
for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 {
reversed[i], reversed[j] = reversed[j], reversed[i]
}
prefix := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(string(reversed))
conditions = append(conditions, fmt.Sprintf("lower(reverse(split_part(server_name, '/', 1))) LIKE lower($%d) ESCAPE '\\'", argIndex))
args = append(args, prefix+"%")
argIndex++
}
}
if filter.Version != nil {
conditions = append(conditions, fmt.Sprintf("version = $%d", argIndex))
Expand Down
78 changes: 78 additions & 0 deletions internal/database/search_suffix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package database_test

import (
"context"
"sort"
"strings"
"testing"
"time"

"github.com/modelcontextprotocol/registry/internal/database"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/require"
)

func TestSearchNamespaceSuffixPreservesLiteralSubstringAndPagination(t *testing.T) {
db := database.NewTestDB(t)
ctx := context.Background()
names := []string{"io.github.example/tool", "org.io.github.example/tool", "io.github.example/tool_extra", "io.github.other/tool", "com.example/100_percent", "com.example/100Xpercent"}
type row struct {
name, version string
deleted bool
}
var fixture []row
for i, name := range names {
for _, version := range []string{"1.0.0", "1.0.1"} {
deleted := i == 1 && version == "1.0.0"
status := model.StatusActive
if deleted {
status = model.StatusDeleted
}
now := time.Now()
_, err := db.CreateServer(ctx, nil, &apiv0.ServerJSON{Name: name, Version: version, Description: "Search equivalence fixture"}, &apiv0.RegistryExtensions{Status: status, PublishedAt: now, UpdatedAt: now, StatusChangedAt: now, IsLatest: version == "1.0.1"})
require.NoError(t, err)
fixture = append(fixture, row{name, version, deleted})
}
}
for _, term := range []string{"github.example/tool", "Io.GiThUb.Example/TOOL", "github.example/", "le/tool", "/tool", "tool", "example/100_", "example/100%", `example/100\`, "example/tool/extra", "例子/工具"} {
for _, includeDeleted := range []bool{false, true} {
for _, latestOnly := range []bool{false, true} {
t.Run(term+"/"+strings.Join([]string{boolLabel(includeDeleted), boolLabel(latestOnly)}, "/"), func(t *testing.T) {
filter := &database.ServerFilter{SubstringName: &term, IncludeDeleted: &includeDeleted}
if latestOnly {
filter.IsLatest = &latestOnly
}
var want, got []string
for _, r := range fixture {
if strings.Contains(strings.ToLower(r.name), strings.ToLower(term)) && (includeDeleted || !r.deleted) && (!latestOnly || r.version == "1.0.1") {
want = append(want, r.name+":"+r.version)
}
}
sort.Strings(want)
cursor := ""
for page := 0; page < 20; page++ {
rows, next, err := db.ListServers(ctx, nil, filter, cursor, 2)
require.NoError(t, err)
for _, r := range rows {
got = append(got, r.Server.Name+":"+r.Server.Version)
}
if next == "" {
break
}
require.NotEqual(t, cursor, next)
cursor = next
}
require.Equal(t, want, got)
})
}
}
}
}

func boolLabel(v bool) string {
if v {
return "true"
}
return "false"
}
Loading
Loading