Skip to content

Commit 923b2e0

Browse files
domdomeggclaude
andauthored
feat: implement DNS namespace validation and remote URL uniqueness checks (#306)
## Summary - Add reverse-DNS namespace format validation for server names (namespace/name) - Validate remote URLs match the DNS namespace derived from hostname - Prevent duplicate remote URLs across different servers - Support localhost/development URLs without namespace restrictions - Add comprehensive test coverage for validation logic - Update database layer to support remote_url filtering Fixes #180 by ensuring proper namespace governance and preventing URL conflicts. --------- Co-authored-by: Claude <[email protected]>
1 parent d663406 commit 923b2e0

File tree

11 files changed

+497
-29
lines changed

11 files changed

+497
-29
lines changed

docs/server-json/examples.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
186186
```json
187187
{
188188
"server": {
189-
"name": "com.example/mcp-fs",
189+
"name": "io.modelcontextprotocol.anonymous/mcp-fs",
190190
"description": "Cloud-hosted MCP filesystem server",
191191
"repository": {
192192
"url": "https://github.com/example/remote-fs",
@@ -199,7 +199,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
199199
"remotes": [
200200
{
201201
"transport_type": "sse",
202-
"url": "http://mcp-fs.example.com/sse"
202+
"url": "http://mcp-fs.anonymous.modelcontextprotocol.io/sse"
203203
}
204204
]
205205
},
@@ -418,7 +418,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
418418
```json
419419
{
420420
"server": {
421-
"name": "com.example/hybrid-mcp",
421+
"name": "io.modelcontextprotocol.anonymous/hybrid-mcp",
422422
"description": "MCP server available as both local package and remote service",
423423
"repository": {
424424
"url": "https://github.com/example/hybrid-mcp",
@@ -448,7 +448,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
448448
"remotes": [
449449
{
450450
"transport_type": "sse",
451-
"url": "https://mcp.example.com/sse",
451+
"url": "https://mcp.anonymous.modelcontextprotocol.io/sse",
452452
"headers": [
453453
{
454454
"name": "X-API-Key",
@@ -466,7 +466,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
466466
},
467467
{
468468
"transport_type": "streamable",
469-
"url": "https://mcp.example.com/http"
469+
"url": "https://mcp.anonymous.modelcontextprotocol.io/http"
470470
}
471471
]
472472
},

internal/api/handlers/v0/publish.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
7171
// Publish the server with extensions
7272
publishedServer, err := registry.Publish(publishRequest)
7373
if err != nil {
74-
return nil, huma.Error500InternalServerError("Failed to publish server", err)
74+
return nil, huma.Error400BadRequest("Failed to publish server", err)
7575
}
7676

7777
// Return the published server in extension wrapper format

internal/api/handlers/v0/publish_integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,10 @@ func TestPublishIntegration(t *testing.T) {
9999
assert.Equal(t, publishReq.Server.VersionDetail.Version, response.Server.VersionDetail.Version)
100100
})
101101

102-
t.Run("successful publish without auth (no prefix)", func(t *testing.T) {
102+
t.Run("successful publish with none auth (no prefix)", func(t *testing.T) {
103103
publishReq := model.PublishRequest{
104104
Server: model.ServerDetail{
105-
Name: "test-mcp-server-no-auth",
105+
Name: "com.example/test-mcp-server-no-auth",
106106
Description: "A test MCP server without authentication",
107107
Repository: model.Repository{
108108
URL: "https://example.com/test-mcp-server",

internal/api/handlers/v0/publish_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,9 @@ func TestPublishEndpoint(t *testing.T) {
203203
},
204204
},
205205
setupMocks: func(registry *MockRegistryService) {
206-
registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(nil, errors.New("database error"))
206+
registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(nil, errors.New("cannot publish duplicate version"))
207207
},
208-
expectedStatus: http.StatusInternalServerError,
208+
expectedStatus: http.StatusBadRequest,
209209
expectedError: "Failed to publish server",
210210
},
211211
}

internal/database/database.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ var (
1313
ErrAlreadyExists = errors.New("record already exists")
1414
ErrInvalidInput = errors.New("invalid input")
1515
ErrDatabase = errors.New("database error")
16-
ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version")
16+
ErrInvalidVersion = errors.New("invalid version: cannot publish duplicate version")
1717
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
1818
)
1919

internal/database/memory.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func NewMemoryDB(e map[string]*model.ServerDetail) *MemoryDB {
4242
}
4343

4444

45-
// List retrieves ServerRecord entries with optional filtering and pagination
45+
//nolint:cyclop // Complexity from filtering logic is acceptable for memory implementation
4646
func (db *MemoryDB) List(
4747
ctx context.Context,
4848
filter map[string]any,
@@ -89,6 +89,18 @@ func (db *MemoryDB) List(
8989
if string(entry.ServerJSON.Status) != value.(string) {
9090
include = false
9191
}
92+
case "remote_url":
93+
found := false
94+
remoteURL := value.(string)
95+
for _, remote := range entry.ServerJSON.Remotes {
96+
if remote.URL == remoteURL {
97+
found = true
98+
break
99+
}
100+
}
101+
if !found {
102+
include = false
103+
}
92104
}
93105
}
94106

internal/database/postgres.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ func (db *PostgreSQL) List(
7676
whereClause += fmt.Sprintf(" AND s.status = $%d", argIndex)
7777
args = append(args, v)
7878
argIndex++
79+
case "remote_url":
80+
whereClause += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(s.remotes) = 'array' THEN s.remotes ELSE '[]'::jsonb END) AS remote WHERE remote->>'url' = $%d)", argIndex)
81+
args = append(args, v)
82+
argIndex++
7983
}
8084
}
8185

internal/model/model.go

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package model
33
import (
44
"encoding/json"
55
"fmt"
6+
"net/url"
7+
"slices"
8+
"strings"
69
"time"
710
)
811

@@ -32,15 +35,13 @@ const (
3235
ServerStatusDeprecated ServerStatus = "deprecated"
3336
)
3437

35-
3638
// Repository represents a source code repository as defined in the spec
3739
type Repository struct {
3840
URL string `json:"url" bson:"url"`
3941
Source string `json:"source" bson:"source"`
4042
ID string `json:"id,omitempty" bson:"id,omitempty"`
4143
}
4244

43-
4445
// create an enum for Format
4546
type Format string
4647

@@ -108,8 +109,7 @@ type VersionDetail struct {
108109
Version string `json:"version" bson:"version"`
109110
}
110111

111-
112-
// ServerDetail represents complete server information as defined in the MCP spec (pure, no registry metadata)
112+
// ServerDetail represents complete server information as defined in the MCP spec (pure, no registry metadata)
113113
type ServerDetail struct {
114114
Name string `json:"name" bson:"name"`
115115
Description string `json:"description" bson:"description"`
@@ -131,8 +131,8 @@ type RegistryMetadata struct {
131131

132132
// ServerRecord represents the complete storage model that separates server.json from registry metadata
133133
type ServerRecord struct {
134-
ServerJSON ServerDetail `json:"server" bson:"server"` // Pure MCP server.json
135-
RegistryMetadata RegistryMetadata `json:"registry_metadata" bson:"registry_metadata"` // Registry-generated data
134+
ServerJSON ServerDetail `json:"server" bson:"server"` // Pure MCP server.json
135+
RegistryMetadata RegistryMetadata `json:"registry_metadata" bson:"registry_metadata"` // Registry-generated data
136136
PublisherExtensions map[string]interface{} `json:"publisher_extensions" bson:"publisher_extensions"` // x-publisher extensions
137137
}
138138

@@ -244,22 +244,114 @@ func ParseServerName(serverDetail ServerDetail) (string, error) {
244244
if name == "" {
245245
return "", fmt.Errorf("server name is required and must be a string")
246246
}
247+
248+
// Validate format: dns-namespace/name
249+
if !strings.Contains(name, "/") {
250+
return "", fmt.Errorf("server name must be in format 'dns-namespace/name' (e.g., 'com.example.api/server')")
251+
}
252+
253+
parts := strings.SplitN(name, "/", 2)
254+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
255+
return "", fmt.Errorf("server name must be in format 'dns-namespace/name' with non-empty namespace and name parts")
256+
}
257+
247258
return name, nil
248259
}
249260

261+
// ValidateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace
262+
func ValidateRemoteNamespaceMatch(serverDetail ServerDetail) error {
263+
namespace := serverDetail.Name
264+
265+
for _, remote := range serverDetail.Remotes {
266+
if err := validateRemoteURLMatchesNamespace(remote.URL, namespace); err != nil {
267+
return fmt.Errorf("remote URL %s does not match namespace %s: %w", remote.URL, namespace, err)
268+
}
269+
}
270+
271+
return nil
272+
}
273+
274+
// validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace
275+
func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error {
276+
// Parse the URL to extract the hostname
277+
parsedURL, err := url.Parse(remoteURL)
278+
if err != nil {
279+
return fmt.Errorf("invalid URL format: %w", err)
280+
}
281+
282+
hostname := parsedURL.Hostname()
283+
if hostname == "" {
284+
return fmt.Errorf("URL must have a valid hostname")
285+
}
286+
287+
// Skip validation for localhost and local development URLs
288+
if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || hostname == "127.0.0.1" {
289+
return nil
290+
}
291+
292+
// Extract publisher domain from reverse-DNS namespace
293+
publisherDomain := extractPublisherDomainFromNamespace(namespace)
294+
if publisherDomain == "" {
295+
return fmt.Errorf("invalid namespace format: cannot extract domain from %s", namespace)
296+
}
297+
298+
// Check if the remote URL hostname matches the publisher domain or is a subdomain
299+
if !isValidHostForDomain(hostname, publisherDomain) {
300+
return fmt.Errorf("remote URL host %s does not match publisher domain %s", hostname, publisherDomain)
301+
}
302+
303+
return nil
304+
}
305+
306+
// extractPublisherDomainFromNamespace converts reverse-DNS namespace to normal domain format
307+
// e.g., "com.example" -> "example.com"
308+
func extractPublisherDomainFromNamespace(namespace string) string {
309+
// Extract the namespace part before the first slash
310+
namespacePart := namespace
311+
if slashIdx := strings.Index(namespace, "/"); slashIdx != -1 {
312+
namespacePart = namespace[:slashIdx]
313+
}
314+
315+
// Split into parts and reverse them to get normal domain format
316+
parts := strings.Split(namespacePart, ".")
317+
if len(parts) < 2 {
318+
return ""
319+
}
320+
321+
// Reverse the parts to convert from reverse-DNS to normal domain
322+
slices.Reverse(parts)
323+
324+
return strings.Join(parts, ".")
325+
}
326+
327+
// isValidHostForDomain checks if a hostname is the domain or a subdomain of the publisher domain
328+
func isValidHostForDomain(hostname, publisherDomain string) bool {
329+
// Exact match
330+
if hostname == publisherDomain {
331+
return true
332+
}
333+
334+
// Subdomain match - hostname should end with "." + publisherDomain
335+
if strings.HasSuffix(hostname, "."+publisherDomain) {
336+
return true
337+
}
338+
339+
return false
340+
}
341+
250342
// ToServerResponse converts a ServerRecord to API response format
251343
func (sr *ServerRecord) ToServerResponse() ServerResponse {
252344
response := ServerResponse{
253345
Server: sr.ServerJSON,
254346
}
255-
347+
256348
// Add registry metadata extension
257349
response.XIOModelContextProtocolRegistry = sr.RegistryMetadata.CreateRegistryExtensions()["x-io.modelcontextprotocol.registry"]
258-
350+
259351
// Add publisher extensions directly
260352
if len(sr.PublisherExtensions) > 0 {
261353
response.XPublisher = sr.PublisherExtensions
262354
}
263-
355+
264356
return response
265357
}

0 commit comments

Comments
 (0)