fix: add HTTP middleware to redirect trailing slash URLs to canonical form (#420)

Resolves #419 where URLs with trailing slashes like `/v0/servers/` would
return empty responses instead of proper data.

**Changes**:
- Added TrailingSlashMiddleware that redirects trailing slash URLs with
301 status
- Preserves root path ('/') without redirecting
- Uses HTTP 301 Moved Permanently to indicate canonical form
- Added comprehensive test coverage for the middleware

This follows REST API best practices and is consistent with how major Go
web frameworks handle trailing slash normalization.

Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
adam jones 2025-09-10 13:14:59 -07:00 • committed by GitHub
commit 6bb62d160e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 118 additions and 1 deletions

View file

@ -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,
},
}

View file

@ -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)
}
}
})
}
}