Skip to content

Commit 9158f12

Browse files
authored
Merge branch 'main' into adamj/filtered-servers-test
2 parents 60f5524 + 17eb56d commit 9158f12

File tree

14 files changed

+400
-9
lines changed

14 files changed

+400
-9
lines changed

docs/guides/publishing/publish-server.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Include your server name in your package README file using this format:
152152

153153
**MCP name format**: `mcp-name: io.github.username/server-name`
154154

155-
Add it to your README.md file (which becomes the package description on PyPI).
155+
Add it to your README.md file (which becomes the package description on PyPI). This can be in a comment if you want to hide it from display elsewhere.
156156

157157
### How It Works
158158
- Registry fetches `https://pypi.org/pypi/your-package/json`
@@ -184,7 +184,7 @@ Include your server name in your package's README using this format:
184184

185185
**MCP name format**: `mcp-name: io.github.username/server-name`
186186

187-
Add a README file to your NuGet package that includes the server name.
187+
Add a README file to your NuGet package that includes the server name. This can be in a comment if you want to hide it from display elsewhere.
188188

189189
### How It Works
190190
- Registry fetches README from `https://api.nuget.org/v3-flatcontainer/{id}/{version}/readme`

docs/reference/api/openapi.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ components:
233233
type: string
234234
example: "1.0.2"
235235
description: "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification."
236+
website_url:
237+
type: string
238+
format: uri
239+
description: "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements."
240+
example: "https://modelcontextprotocol.io/examples"
236241
created_at:
237242
type: string
238243
format: date-time

docs/reference/server-json/generic-server-json.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ The official registry has some more restrictions on top of this. See the [offici
2323
```json
2424
{
2525
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
26-
"name": "io.modelcontextprotocol/brave-search",
26+
"name": "io.modelcontextprotocol.anonymous/brave-search",
2727
"description": "MCP server for Brave Search API integration",
2828
"status": "active",
29+
"website_url": "https://anonymous.modelcontextprotocol.io/examples",
2930
"repository": {
3031
"url": "https://github.com/modelcontextprotocol/servers",
3132
"source": "github"
@@ -654,6 +655,21 @@ Some CLI tools bundle an MCP server, without a standalone MCP package or a publi
654655
}
655656
```
656657

658+
### Server with Custom Installation Path
659+
660+
For MCP servers that follow a custom installation path or are embedded in applications without standalone packages, use the `website_url` field to direct users to setup documentation:
661+
662+
```json
663+
{
664+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
665+
"name": "io.modelcontextprotocol.anonymous/embedded-mcp",
666+
"description": "MCP server embedded in a Desktop app",
667+
"status": "active",
668+
"website_url": "https://anonymous.modelcontextprotocol.io/embedded-mcp-guide",
669+
"version": "0.1.0"
670+
}
671+
```
672+
657673
### Deprecated Server Example
658674

659675
```json

docs/reference/server-json/server.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@
7373
"maxLength": 255,
7474
"example": "1.0.2",
7575
"description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably."
76+
},
77+
"website_url": {
78+
"type": "string",
79+
"format": "uri",
80+
"description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.",
81+
"example": "https://modelcontextprotocol.io/examples"
7682
}
7783
}
7884
},

internal/api/handlers/v0/publish.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
5050

5151
// Verify that the token has permission to publish the server
5252
if !jwtManager.HasPermission(input.Body.Name, auth.PermissionActionPublish, claims.Permissions) {
53-
return nil, huma.Error403Forbidden("You do not have permission to publish this server")
53+
return nil, huma.Error403Forbidden(buildPermissionErrorMessage(input.Body.Name, claims.Permissions))
5454
}
5555

5656
// Publish the server with extensions
@@ -65,3 +65,24 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
6565
}, nil
6666
})
6767
}
68+
69+
// buildPermissionErrorMessage creates a detailed error message showing what permissions
70+
// the user has and what they're trying to publish
71+
func buildPermissionErrorMessage(attemptedResource string, permissions []auth.Permission) string {
72+
var permissionStrs []string
73+
for _, perm := range permissions {
74+
if perm.Action == auth.PermissionActionPublish {
75+
permissionStrs = append(permissionStrs, perm.ResourcePattern)
76+
}
77+
}
78+
79+
errorMsg := "You do not have permission to publish this server"
80+
if len(permissionStrs) > 0 {
81+
errorMsg += ". You have permission to publish: " + strings.Join(permissionStrs, ", ")
82+
} else {
83+
errorMsg += ". You do not have any publish permissions"
84+
}
85+
errorMsg += ". Attempting to publish: " + attemptedResource
86+
87+
return errorMsg
88+
}

internal/api/router/router.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
package router
33

44
import (
5+
"encoding/json"
6+
"fmt"
57
"net/http"
68
"strings"
79
"time"
@@ -91,6 +93,40 @@ func WithSkipPaths(paths ...string) MiddlewareOption {
9193
}
9294
}
9395

96+
// handle404 returns a helpful 404 error with suggestions for common mistakes
97+
func handle404(w http.ResponseWriter, r *http.Request) {
98+
w.Header().Set("Content-Type", "application/problem+json")
99+
w.WriteHeader(http.StatusNotFound)
100+
101+
path := r.URL.Path
102+
detail := "Endpoint not found. See /docs for the API documentation."
103+
104+
// Provide suggestions for common API endpoint mistakes
105+
if !strings.HasPrefix(path, "/v0/") {
106+
detail = fmt.Sprintf(
107+
"Endpoint not found. Did you mean '%s'? See /docs for the API documentation.",
108+
"/v0"+path,
109+
)
110+
}
111+
112+
errorBody := map[string]interface{}{
113+
"title": "Not Found",
114+
"status": 404,
115+
"detail": detail,
116+
}
117+
118+
// Use JSON marshal to ensure consistent formatting
119+
jsonData, err := json.Marshal(errorBody)
120+
if err != nil {
121+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
122+
return
123+
}
124+
_, err = w.Write(jsonData)
125+
if err != nil {
126+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
127+
}
128+
}
129+
94130
// NewHumaAPI creates a new Huma API with all routes registered
95131
func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http.ServeMux, metrics *telemetry.Metrics) huma.API {
96132
// Create Huma API configuration
@@ -113,11 +149,15 @@ func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http.
113149
// Add /metrics for Prometheus metrics using promhttp
114150
mux.Handle("/metrics", metrics.PrometheusHandler())
115151

116-
// Add redirect from / to docs
152+
// Add redirect from / to docs and 404 handler for all other routes
117153
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
118154
if r.URL.Path == "/" {
119155
http.Redirect(w, r, "https://github.com/modelcontextprotocol/registry/tree/main/docs", http.StatusTemporaryRedirect)
156+
return
120157
}
158+
159+
// Handle 404 for all other routes
160+
handle404(w, r)
121161
})
122162

123163
return api

internal/api/server.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"log"
66
"net/http"
7+
"strings"
78
"time"
89

910
"github.com/danielgtaylor/huma/v2"
@@ -14,6 +15,24 @@ import (
1415
"github.com/modelcontextprotocol/registry/internal/telemetry"
1516
)
1617

18+
// TrailingSlashMiddleware redirects requests with trailing slashes to their canonical form
19+
func TrailingSlashMiddleware(next http.Handler) http.Handler {
20+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
// Only redirect if the path is not "/" and ends with a "/"
22+
if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
23+
// Create a copy of the URL and remove the trailing slash
24+
newURL := *r.URL
25+
newURL.Path = strings.TrimSuffix(r.URL.Path, "/")
26+
27+
// Use 308 Permanent Redirect to preserve the request method
28+
http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect)
29+
return
30+
}
31+
32+
next.ServeHTTP(w, r)
33+
})
34+
}
35+
1736
// Server represents the HTTP server
1837
type Server struct {
1938
config *config.Config
@@ -29,13 +48,16 @@ func NewServer(cfg *config.Config, registryService service.RegistryService, metr
2948

3049
api := router.NewHumaAPI(cfg, registryService, mux, metrics)
3150

51+
// Wrap the mux with trailing slash middleware
52+
handler := TrailingSlashMiddleware(mux)
53+
3254
server := &Server{
3355
config: cfg,
3456
registry: registryService,
3557
humaAPI: api,
3658
server: &http.Server{
3759
Addr: cfg.ServerAddress,
38-
Handler: mux,
60+
Handler: handler,
3961
ReadHeaderTimeout: 10 * time.Second,
4062
},
4163
}

internal/api/server_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package api_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/modelcontextprotocol/registry/internal/api"
9+
)
10+
11+
func TestTrailingSlashMiddleware(t *testing.T) {
12+
// Create a simple handler that returns "OK"
13+
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
14+
w.WriteHeader(http.StatusOK)
15+
_, _ = w.Write([]byte("OK"))
16+
})
17+
18+
// Wrap with our middleware
19+
middleware := api.TrailingSlashMiddleware(handler)
20+
21+
tests := []struct {
22+
name string
23+
path string
24+
expectedStatus int
25+
expectedLocation string
26+
expectRedirect bool
27+
}{
28+
{
29+
name: "root path should not redirect",
30+
path: "/",
31+
expectedStatus: http.StatusOK,
32+
expectRedirect: false,
33+
},
34+
{
35+
name: "path without trailing slash should pass through",
36+
path: "/v0/servers",
37+
expectedStatus: http.StatusOK,
38+
expectRedirect: false,
39+
},
40+
{
41+
name: "path with trailing slash should redirect",
42+
path: "/v0/servers/",
43+
expectedStatus: http.StatusPermanentRedirect,
44+
expectedLocation: "/v0/servers",
45+
expectRedirect: true,
46+
},
47+
{
48+
name: "nested path with trailing slash should redirect",
49+
path: "/v0/servers/123/",
50+
expectedStatus: http.StatusPermanentRedirect,
51+
expectedLocation: "/v0/servers/123",
52+
expectRedirect: true,
53+
},
54+
{
55+
name: "deep nested path with trailing slash should redirect",
56+
path: "/v0/auth/github/token/",
57+
expectedStatus: http.StatusPermanentRedirect,
58+
expectedLocation: "/v0/auth/github/token",
59+
expectRedirect: true,
60+
},
61+
{
62+
name: "path with query params and no trailing slash should pass through",
63+
path: "/v0/servers?limit=10",
64+
expectedStatus: http.StatusOK,
65+
expectRedirect: false,
66+
},
67+
{
68+
name: "path with query params and trailing slash should redirect preserving query params",
69+
path: "/v0/servers/?limit=10",
70+
expectedStatus: http.StatusPermanentRedirect,
71+
expectedLocation: "/v0/servers?limit=10",
72+
expectRedirect: true,
73+
},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
79+
w := httptest.NewRecorder()
80+
81+
middleware.ServeHTTP(w, req)
82+
83+
if w.Code != tt.expectedStatus {
84+
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
85+
}
86+
87+
if tt.expectRedirect {
88+
location := w.Header().Get("Location")
89+
if location != tt.expectedLocation {
90+
t.Errorf("expected Location header %q, got %q", tt.expectedLocation, location)
91+
}
92+
}
93+
})
94+
}
95+
}

internal/validators/registries/npm.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ func ValidateNPM(ctx context.Context, pkg model.Package, serverName string) erro
2222
pkg.RegistryBaseURL = model.RegistryURLNPM
2323
}
2424

25+
if pkg.Identifier == "" {
26+
return fmt.Errorf("package identifier is required for NPM packages")
27+
}
28+
29+
// we need version to look up the package metadata
30+
// not providing version will return all the versions
31+
// and we won't be able to validate the mcpName field
32+
// against the server name
33+
if pkg.Version == "" {
34+
return fmt.Errorf("package version is required for NPM packages")
35+
}
36+
2537
// Validate that the registry base URL matches NPM exactly
2638
if pkg.RegistryBaseURL != model.RegistryURLNPM {
2739
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",

internal/validators/registries/npm_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,30 @@ func TestValidateNPM_RealPackages(t *testing.T) {
2020
expectError bool
2121
errorMessage string
2222
}{
23+
{
24+
name: "empty package identifier should fail",
25+
packageName: "",
26+
version: "1.0.0",
27+
serverName: "com.example/test",
28+
expectError: true,
29+
errorMessage: "package identifier is required for NPM packages",
30+
},
31+
{
32+
name: "empty package version should fail",
33+
packageName: "test-package",
34+
version: "",
35+
serverName: "com.example/test",
36+
expectError: true,
37+
errorMessage: "package version is required for NPM packages",
38+
},
39+
{
40+
name: "both empty identifier and version should fail with identifier error first",
41+
packageName: "",
42+
version: "",
43+
serverName: "com.example/test",
44+
expectError: true,
45+
errorMessage: "package identifier is required for NPM packages",
46+
},
2347
{
2448
name: "non-existent package should fail",
2549
packageName: generateRandomPackageName(),

0 commit comments

Comments
 (0)