diff --git a/internal/api/server.go b/internal/api/server.go index 97f145d..523cfec 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -4,6 +4,7 @@ import ( "context" "log" "net/http" + "strings" "time" "github.com/danielgtaylor/huma/v2" @@ -14,6 +15,24 @@ import ( "github.com/modelcontextprotocol/registry/internal/telemetry" ) +// TrailingSlashMiddleware redirects requests with trailing slashes to their canonical form +func TrailingSlashMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only redirect if the path is not "/" and ends with a "/" + if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") { + // Create a copy of the URL and remove the trailing slash + newURL := *r.URL + newURL.Path = strings.TrimSuffix(r.URL.Path, "/") + + // Use 308 Permanent Redirect to preserve the request method + http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect) + return + } + + next.ServeHTTP(w, r) + }) +} + // Server represents the HTTP server type Server struct { config *config.Config @@ -29,13 +48,16 @@ func NewServer(cfg *config.Config, registryService service.RegistryService, metr api := router.NewHumaAPI(cfg, registryService, mux, metrics) + // Wrap the mux with trailing slash middleware + handler := TrailingSlashMiddleware(mux) + server := &Server{ config: cfg, registry: registryService, humaAPI: api, server: &http.Server{ Addr: cfg.ServerAddress, - Handler: mux, + Handler: handler, ReadHeaderTimeout: 10 * time.Second, }, } diff --git a/internal/api/server_test.go b/internal/api/server_test.go new file mode 100644 index 0000000..6309b47 --- /dev/null +++ b/internal/api/server_test.go @@ -0,0 +1,95 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/registry/internal/api" +) + +func TestTrailingSlashMiddleware(t *testing.T) { + // Create a simple handler that returns "OK" + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + // Wrap with our middleware + middleware := api.TrailingSlashMiddleware(handler) + + tests := []struct { + name string + path string + expectedStatus int + expectedLocation string + expectRedirect bool + }{ + { + name: "root path should not redirect", + path: "/", + expectedStatus: http.StatusOK, + expectRedirect: false, + }, + { + name: "path without trailing slash should pass through", + path: "/v0/servers", + expectedStatus: http.StatusOK, + expectRedirect: false, + }, + { + name: "path with trailing slash should redirect", + path: "/v0/servers/", + expectedStatus: http.StatusPermanentRedirect, + expectedLocation: "/v0/servers", + expectRedirect: true, + }, + { + name: "nested path with trailing slash should redirect", + path: "/v0/servers/123/", + expectedStatus: http.StatusPermanentRedirect, + expectedLocation: "/v0/servers/123", + expectRedirect: true, + }, + { + name: "deep nested path with trailing slash should redirect", + path: "/v0/auth/github/token/", + expectedStatus: http.StatusPermanentRedirect, + expectedLocation: "/v0/auth/github/token", + expectRedirect: true, + }, + { + name: "path with query params and no trailing slash should pass through", + path: "/v0/servers?limit=10", + expectedStatus: http.StatusOK, + expectRedirect: false, + }, + { + name: "path with query params and trailing slash should redirect preserving query params", + path: "/v0/servers/?limit=10", + expectedStatus: http.StatusPermanentRedirect, + expectedLocation: "/v0/servers?limit=10", + expectRedirect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + + middleware.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) + } + + if tt.expectRedirect { + location := w.Header().Get("Location") + if location != tt.expectedLocation { + t.Errorf("expected Location header %q, got %q", tt.expectedLocation, location) + } + } + }) + } +}