api: Use Huma to generate swagger docs and parse JSON automatically (#262)

## Motivation and Context

Currently there's a lot of unsafe manual JSON unmarshalling/marshalling,
and we have to manually keep the openapi schema in sync.

This PR migrates the API to
[Huma](https://github.com/danielgtaylor/huma), which appears like a
popular, well-maintained way to build APIs in Golang. I'm not a Golang
expert, but seemed highly recommended by Reddit + Claude and has a
decent star count. It provides automatic openapi spec generation and
docs hosting, JSON parsing, as well as the ability to add auth
middlewares etc.

## How Has This Been Tested?

Running locally, and poking at the endpoints

## Breaking Changes

Docs are served at `/docs` rather than `/v0/swagger/index.html` now

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->
This commit is contained in:
adam jones 2025-08-13 16:57:55 +01:00 • committed by GitHub
commit 6b84493ca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1622 additions and 2267 deletions

View file

@ -8,7 +8,6 @@ FROM alpine:latest
WORKDIR /app
COPY --from=builder /build/registry .
COPY --from=builder /app/data/seed.json /app/data/seed.json
COPY --from=builder /app/internal/docs/swagger.yaml /app/internal/docs/swagger.yaml
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/

View file

@ -197,7 +197,7 @@ Publishing requires GitHub OAuth validation:
### API Documentation
```
GET /v0/swagger/index.html
GET /docs
```
The API is documented using Swagger/OpenAPI. This page provides a complete reference of all endpoints with request/response schemas and examples, and allows you to test the API directly from your browser.

View file

@ -18,7 +18,7 @@ The MCP Registry is the official centralized metadata repository for publicly-ac
There are four underlying concepts:
- "MCP Server Registry API" (or "MCP Registry API"): The OpenAPI specification defined in [openapi.yaml](./server-registry-api/openapi.yaml). This is a reusable API specification that anyone building any sort of "MCP server registry" should consider adopting / aligning with.
- "Official MCP Registry" (or "MCP Registry"): The application that lives at `https://registry.modelcontextprotocol.io`. This registry currently only catalogs MCP servers, but may be extended in the future to also catalog MCP client/host apps and frameworks.
- "Official MCP Registry API": The REST API that lives at `https://registry.modelcontextprotocol.io/api`, with an OpenAPI specification defined at [swagger.yaml](../internal/docs/swagger.yaml)
- "Official MCP Registry API": The REST API served at `https://registry.modelcontextprotocol.io`, which is a superset of the MCP Registry API. Its OpenAPI specification can be downloaded from [https://registry.modelcontextprotocol.io/openapi.yaml](https://registry.modelcontextprotocol.io/openapi.yaml)
- "MCP server registry" (or "MCP registry"): A third party, likely commercial, implementation of the MCP Server Registry API or derivative specification.
### Is the MCP Registry a package registry?

View file

@ -10,4 +10,4 @@ The centralized, publicly available catalog ("Official MCP Registry") needs addi
References:
- [openapi.yaml](./openapi.yaml) - A reusable API specification (MCP Server Registry API) that anyone building any sort of "MCP server registry" should consider adopting / aligning with.
- [swagger.yaml](../../internal/docs/swagger.yaml) - The specification backing the Official MCP Registry; a derivative of the MCP Server Registry API specification.
- [https://registry.modelcontextprotocol.io/openapi.yaml](https://registry.modelcontextprotocol.io/openapi.yaml) - The specification backing the Official MCP Registry; a derivative of the MCP Server Registry API specification.

18
go.mod
View file

@ -4,30 +4,22 @@ go 1.24.0
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/danielgtaylor/huma/v2 v2.34.1
github.com/google/uuid v1.6.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/stretchr/testify v1.10.0
github.com/swaggo/files v1.0.1
github.com/swaggo/http-swagger v1.3.4
go.mongodb.org/mongo-driver v1.17.3
golang.org/x/net v0.42.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/swag v1.16.4 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
@ -35,6 +27,6 @@ require (
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

44
go.sum
View file

@ -1,33 +1,25 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -40,12 +32,6 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
@ -62,14 +48,9 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
@ -79,22 +60,17 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View file

@ -1,126 +1,101 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"io"
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/model"
)
// StartAuthHandler handles requests to start an authentication flow
func StartAuthHandler(authService auth.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only allow POST method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// StartAuthInput represents the input for starting an auth flow
type StartAuthInput struct {
Body struct {
Method string `json:"method" doc:"Authentication method" example:"github"`
RepoRef string `json:"repo_ref" doc:"Repository reference" example:"owner/repo"`
}
}
// Read the request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// StartAuthBody represents the auth flow start response body
type StartAuthBody struct {
AuthFlowInfo map[string]string `json:"auth_flow_info" doc:"Authentication flow information"`
StatusToken string `json:"status_token" doc:"Token to check auth status"`
}
// Parse request body into AuthRequest struct
var authReq struct {
Method string `json:"method"`
RepoRef string `json:"repo_ref"`
}
err = json.Unmarshal(body, &authReq)
if err != nil {
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
return
}
// CheckAuthStatusInput represents the input for checking auth status
type CheckAuthStatusInput struct {
StatusToken string `path:"token" doc:"Status token from auth flow start"`
}
// CheckAuthStatusBody represents the auth status response body
type CheckAuthStatusBody struct {
Status string `json:"status" doc:"Authentication status" example:"pending"`
}
// RegisterAuthEndpoints registers all auth-related endpoints
func RegisterAuthEndpoints(api huma.API, authService auth.Service) {
// Start auth flow endpoint
huma.Register(api, huma.Operation{
OperationID: "start-auth",
Method: http.MethodPost,
Path: "/v0/auth/start",
Summary: "Start authentication flow",
Description: "Start an authentication flow for publishing servers",
Tags: []string{"auth"},
}, func(ctx context.Context, input *StartAuthInput) (*Response[StartAuthBody], error) {
// Validate required fields
if authReq.Method == "" {
http.Error(w, "Auth method is required", http.StatusBadRequest)
return
if input.Body.Method == "" {
return nil, huma.Error400BadRequest("Auth method is required")
}
if input.Body.RepoRef == "" {
return nil, huma.Error400BadRequest("Repository reference is required")
}
// Convert string method to enum type
var method model.AuthMethod
switch authReq.Method {
// Convert string method to AuthMethod type
var authMethod model.AuthMethod
switch input.Body.Method {
case "github":
method = model.AuthMethodGitHub
authMethod = model.AuthMethodGitHub
case "none":
authMethod = model.AuthMethodNone
default:
http.Error(w, "Unsupported authentication method", http.StatusBadRequest)
return
return nil, huma.Error400BadRequest("Invalid auth method: " + input.Body.Method)
}
// Start auth flow
flowInfo, statusToken, err := authService.StartAuthFlow(r.Context(), method, authReq.RepoRef)
// Start the auth flow
flowInfo, statusToken, err := authService.StartAuthFlow(ctx, authMethod, input.Body.RepoRef)
if err != nil {
http.Error(w, "Failed to start auth flow: "+err.Error(), http.StatusInternalServerError)
return
return nil, huma.Error500InternalServerError("Failed to start auth flow", err)
}
// Return successful response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]any{
"flow_info": flowInfo,
"status_token": statusToken,
"expires_in": 300, // 5 minutes
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
return &Response[StartAuthBody]{
Body: StartAuthBody{
AuthFlowInfo: flowInfo,
StatusToken: statusToken,
},
}, nil
})
// CheckAuthStatusHandler handles requests to check the status of an authentication flow
func CheckAuthStatusHandler(authService auth.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only allow GET method
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get status token from query parameter
statusToken := r.URL.Query().Get("token")
if statusToken == "" {
http.Error(w, "Status token is required", http.StatusBadRequest)
return
}
// Check auth status
token, err := authService.CheckAuthStatus(r.Context(), statusToken)
// Check auth status endpoint
huma.Register(api, huma.Operation{
OperationID: "check-auth-status",
Method: http.MethodGet,
Path: "/v0/auth/status/{token}",
Summary: "Check authentication status",
Description: "Check the status of an ongoing authentication flow",
Tags: []string{"auth"},
}, func(ctx context.Context, input *CheckAuthStatusInput) (*Response[CheckAuthStatusBody], error) {
// Check the auth status
status, err := authService.CheckAuthStatus(ctx, input.StatusToken)
if err != nil {
if err.Error() == "pending" {
// Auth is still pending
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "pending",
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
// Other error
http.Error(w, "Failed to check auth status: "+err.Error(), http.StatusInternalServerError)
return
return nil, huma.Error404NotFound("Auth flow not found or expired")
}
// Authentication completed successfully
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "complete",
"token": token,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
return &Response[CheckAuthStatusBody]{
Body: CheckAuthStatusBody{
Status: status,
},
}, nil
})
}

View file

@ -1,27 +1,34 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/config"
)
type HealthResponse struct {
Status string `json:"status"`
GitHubClientID string `json:"github_client_id"`
// HealthBody represents the health check response body
type HealthBody struct {
Status string `json:"status" example:"ok" doc:"Health status"`
GitHubClientID string `json:"github_client_id,omitempty" doc:"GitHub OAuth App Client ID"`
}
// HealthHandler returns a handler for health check endpoint
func HealthHandler(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(HealthResponse{
Status: "ok",
GitHubClientID: cfg.GithubClientID,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// RegisterHealthEndpoint registers the health check endpoint
func RegisterHealthEndpoint(api huma.API, cfg *config.Config) {
huma.Register(api, huma.Operation{
OperationID: "get-health",
Method: http.MethodGet,
Path: "/v0/health",
Summary: "Health check",
Description: "Check the health status of the API",
Tags: []string{"health"},
}, func(_ context.Context, _ *struct{}) (*Response[HealthBody], error) {
return &Response[HealthBody]{
Body: HealthBody{
Status: "ok",
GitHubClientID: cfg.GithubClientID,
},
}, nil
})
}

View file

@ -1,24 +1,24 @@
package v0_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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/config"
"github.com/stretchr/testify/assert"
)
func TestHealthHandler(t *testing.T) {
func TestHealthEndpoint(t *testing.T) {
// Test cases
testCases := []struct {
name string
config *config.Config
expectedStatus int
expectedBody v0.HealthResponse
expectedBody v0.HealthBody
}{
{
name: "returns health status with github client id",
@ -26,18 +26,18 @@ func TestHealthHandler(t *testing.T) {
GithubClientID: "test-github-client-id",
},
expectedStatus: http.StatusOK,
expectedBody: v0.HealthResponse{
expectedBody: v0.HealthBody{
Status: "ok",
GitHubClientID: "test-github-client-id",
},
},
{
name: "works with empty github client id",
name: "returns health status without github client id",
config: &config.Config{
GithubClientID: "",
},
expectedStatus: http.StatusOK,
expectedBody: v0.HealthResponse{
expectedBody: v0.HealthBody{
Status: "ok",
GitHubClientID: "",
},
@ -46,77 +46,33 @@ func TestHealthHandler(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create handler with the test config
handler := v0.HealthHandler(tc.config)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the health endpoint
v0.RegisterHealthEndpoint(api, tc.config)
// Create request
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil)
if err != nil {
t.Fatal(err)
}
// Create a test request
req := httptest.NewRequest(http.MethodGet, "/v0/health", nil)
w := httptest.NewRecorder()
// Create response recorder
rr := httptest.NewRecorder()
// Serve the request
mux.ServeHTTP(w, req)
// Call the handler
handler.ServeHTTP(rr, req)
// Check status code
assert.Equal(t, tc.expectedStatus, rr.Code)
// Check content type
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
// Parse response body
var resp v0.HealthResponse
err = json.NewDecoder(rr.Body).Decode(&resp)
assert.NoError(t, err)
// Check the status code
assert.Equal(t, tc.expectedStatus, w.Code)
// Check the response body
assert.Equal(t, tc.expectedBody, resp)
// Since Huma adds a $schema field, we'll check individual fields
body := w.Body.String()
assert.Contains(t, body, `"status":"ok"`)
if tc.config.GithubClientID != "" {
assert.Contains(t, body, `"github_client_id":"test-github-client-id"`)
} else {
assert.NotContains(t, body, `"github_client_id"`)
}
})
}
}
// TestHealthHandlerIntegration tests the handler with actual HTTP requests
func TestHealthHandlerIntegration(t *testing.T) {
// Create test server
cfg := &config.Config{
GithubClientID: "integration-test-client-id",
}
server := httptest.NewServer(v0.HealthHandler(cfg))
defer server.Close()
// Send request to the test server
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var healthResp v0.HealthResponse
err = json.NewDecoder(resp.Body).Decode(&healthResp)
assert.NoError(t, err)
// Check the response body
expectedResp := v0.HealthResponse{
Status: "ok",
GitHubClientID: "integration-test-client-id",
}
assert.Equal(t, expectedResp, healthResp)
}
}

View file

@ -1,50 +0,0 @@
package v0_test
import (
"context"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/stretchr/testify/mock"
)
// MockRegistryService is a mock implementation of the RegistryService interface
type MockRegistryService struct {
mock.Mock
}
func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) {
args := m.Called(cursor, limit)
return args.Get(0).([]model.Server), args.String(1), args.Error(2)
}
func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) {
args := m.Called(id)
return args.Get(0).(*model.ServerDetail), args.Error(1)
}
func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error {
args := m.Called(serverDetail)
return args.Error(0)
}
// MockAuthService is a mock implementation of the auth.Service interface
type MockAuthService struct {
mock.Mock
}
func (m *MockAuthService) StartAuthFlow(
ctx context.Context, method model.AuthMethod, repoRef string,
) (map[string]string, string, error) {
args := m.Called(ctx, method, repoRef)
return args.Get(0).(map[string]string), args.String(1), args.Error(2)
}
func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) {
args := m.Called(ctx, statusToken)
return args.String(0), args.Error(1)
}
func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) {
args := m.Called(ctx, authentication)
return args.Bool(0), args.Error(1)
}

View file

@ -1,29 +1,31 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"context"
"net/http"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/danielgtaylor/huma/v2"
)
// PingHandler returns a handler for the ping endpoint that returns build version
func PingHandler(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "ok",
"version": cfg.Version,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// PingBody represents the ping response body
type PingBody struct {
Pong bool `json:"pong" example:"true" doc:"Ping response"`
}
// RegisterPingEndpoint registers the ping endpoint
func RegisterPingEndpoint(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "ping",
Method: http.MethodGet,
Path: "/v0/ping",
Summary: "Ping",
Description: "Simple ping endpoint",
Tags: []string{"ping"},
}, func(_ context.Context, _ *struct{}) (*Response[PingBody], error) {
return &Response[PingBody]{
Body: PingBody{
Pong: true,
},
}, nil
})
}

View file

@ -0,0 +1,35 @@
package v0_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/stretchr/testify/assert"
)
func TestPingEndpoint(t *testing.T) {
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the ping endpoint
v0.RegisterPingEndpoint(api)
// Create a test request
req := httptest.NewRequest(http.MethodGet, "/v0/ping", nil)
w := httptest.NewRecorder()
// Serve the request
mux.ServeHTTP(w, req)
// Check the status code
assert.Equal(t, http.StatusOK, w.Code)
// Check the response body contains pong
body := w.Body.String()
assert.Contains(t, body, `"pong":true`)
}

View file

@ -1,133 +1,96 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"errors"
"io"
"context"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/modelcontextprotocol/registry/internal/service"
"golang.org/x/net/html"
)
// PublishHandler handles requests to publish new server details to the registry
func PublishHandler(registry service.RegistryService, authService auth.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Only allow POST method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// PublishServerInput represents the input for publishing a server
type PublishServerInput struct {
Authorization string `header:"Authorization" doc:"GitHub OAuth token" required:"true"`
Body model.PublishRequest
}
// Read the request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Parse request body into PublishRequest struct
var publishReq model.PublishRequest
err = json.Unmarshal(body, &publishReq)
if err != nil {
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
return
// RegisterPublishEndpoint registers the publish endpoint
func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, authService auth.Service) {
huma.Register(api, huma.Operation{
OperationID: "publish-server",
Method: http.MethodPost,
Path: "/v0/publish",
Summary: "Publish MCP server",
Description: "Publish a new MCP server to the registry or update an existing one",
Tags: []string{"publish"},
Security: []map[string][]string{
{"bearer": {}},
},
}, func(ctx context.Context, input *PublishServerInput) (*Response[model.Server], error) {
// Extract bearer token
const bearerPrefix = "Bearer "
authHeader := input.Authorization
if len(authHeader) < len(bearerPrefix) || !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
return nil, huma.Error401Unauthorized("Invalid Authorization header format. Expected 'Bearer <token>'")
}
token := authHeader[len(bearerPrefix):]
// Get server details from the request
var serverDetail model.ServerDetail
// Convert PublishRequest body to ServerDetail
serverDetail := input.Body.ServerDetail
err = json.Unmarshal(body, &serverDetail)
if err != nil {
http.Error(w, "Invalid server detail payload: "+err.Error(), http.StatusBadRequest)
return
}
// Validate required fields
// Huma handles validation automatically based on struct tags
// But we can add custom validation if needed
if serverDetail.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
return nil, huma.Error400BadRequest("Name is required")
}
// Version is required
if serverDetail.VersionDetail.Version == "" {
http.Error(w, "Version is required", http.StatusBadRequest)
return
}
// Get auth token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header is required", http.StatusUnauthorized)
return
}
// Handle bearer token format (e.g., "Bearer xyz123")
token := authHeader
if len(authHeader) > 7 && strings.ToUpper(authHeader[:7]) == "BEARER " {
token = authHeader[7:]
return nil, huma.Error400BadRequest("Version is required")
}
// Determine authentication method based on server name prefix
var authMethod model.AuthMethod
switch {
case strings.HasPrefix(serverDetail.Name, "io.github"):
if strings.HasPrefix(serverDetail.Name, "io.github") {
authMethod = model.AuthMethodGitHub
// Additional cases can be added here for other prefixes
default:
// Keep the default auth method as AuthMethodNone
} else {
authMethod = model.AuthMethodNone
}
serverName := html.EscapeString(serverDetail.Name)
// Setup authentication info
a := model.Authentication{
Method: authMethod,
Token: token,
RepoRef: serverName,
RepoRef: serverDetail.Name,
}
valid, err := authService.ValidateAuth(r.Context(), a)
// Validate authentication
valid, err := authService.ValidateAuth(ctx, a)
if err != nil {
if errors.Is(err, auth.ErrAuthRequired) {
http.Error(w, "Authentication is required for publishing", http.StatusUnauthorized)
return
}
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
return
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
if !valid {
http.Error(w, "Invalid authentication credentials", http.StatusUnauthorized)
return
return nil, huma.Error401Unauthorized("Invalid authentication credentials")
}
// Call the publish method on the registry service
// Publish the server details
err = registry.Publish(&serverDetail)
if err != nil {
// Check for specific error types and return appropriate HTTP status codes
if errors.Is(err, database.ErrInvalidVersion) || errors.Is(err, database.ErrAlreadyExists) {
http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusInternalServerError)
return
return nil, huma.Error500InternalServerError("Failed to publish server", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]string{
"message": "Server publication successful",
"id": serverDetail.ID,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// Create response with the published server data
return &Response[model.Server]{
Body: model.Server{
ID: serverDetail.ID,
Name: serverDetail.Name,
Description: serverDetail.Description,
Status: serverDetail.Status,
Repository: serverDetail.Repository,
VersionDetail: serverDetail.VersionDetail,
},
}, nil
})
}

View file

@ -0,0 +1,731 @@
package v0_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"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/model"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestPublishIntegration(t *testing.T) {
// Setup fake service and auth service
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, authService)
t.Run("successful publish with GitHub auth", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.testuser/test-mcp-server",
Description: "A test MCP server for integration testing",
Repository: model.Repository{
URL: "https://github.com/testuser/test-mcp-server",
Source: "github",
ID: "testuser/test-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
Packages: []model.Package{
{
RegistryName: "npm",
Name: "test-mcp-server",
Version: "1.0.0",
RunTimeHint: "node",
RuntimeArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "config",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Configuration file path",
Format: model.FormatFilePath,
IsRequired: true,
},
},
},
},
},
},
Remotes: []model.Remote{
{
TransportType: "http",
URL: "http://localhost:3000/mcp",
},
},
},
}
// Marshal the server detail to JSON
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
// Create a request
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_test_token_123")
// Create a response recorder
recorder := httptest.NewRecorder()
// Call the handler through the mux
mux.ServeHTTP(recorder, req)
// Check the response
assert.Equal(t, http.StatusOK, recorder.Code)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotEmpty(t, response.ID, "Server ID should be generated")
assert.Equal(t, publishReq.Name, response.Name)
assert.Equal(t, publishReq.Description, response.Description)
// Verify the server was actually published by retrieving it
publishedServer, err := registryService.GetByID(response.ID)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, publishedServer.Name)
assert.Equal(t, publishReq.Description, publishedServer.Description)
assert.Equal(t, publishReq.VersionDetail.Version, publishedServer.VersionDetail.Version)
assert.Len(t, publishedServer.Packages, 1)
assert.Len(t, publishedServer.Remotes, 1)
})
t.Run("successful publish without auth (no prefix)", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "custom-mcp-server",
Description: "A custom MCP server without auth",
Repository: model.Repository{
URL: "https://example.com/custom-server",
Source: "custom",
ID: "custom/custom-server",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer dummy_token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotEmpty(t, response.ID, "Server ID should be generated")
})
t.Run("publish fails with missing name", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "", // Missing name
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Name is required")
})
t.Run("publish fails with missing version", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "", // Missing version
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Version is required")
})
t.Run("publish fails with missing authorization header", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
// No Authorization header
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
assert.Contains(t, recorder.Body.String(), "required header parameter is missing")
})
t.Run("publish fails with invalid JSON", func(t *testing.T) {
invalidJSON := `{"name": "test", "version": `
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBufferString(invalidJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "unexpected end of JSON")
})
t.Run("publish fails with unsupported HTTP method", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v0/publish", nil)
req.Header.Set("Authorization", "Bearer token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Method Not Allowed")
})
t.Run("publish fails with duplicate name and version", func(t *testing.T) {
// First, publish a server successfully
firstServerDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.duplicate/test-server",
Description: "First server for duplicate test",
Repository: model.Repository{
URL: "https://github.com/duplicate/test-server",
Source: "github",
ID: "duplicate/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(firstServerDetail)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_token_first")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "First publish should succeed")
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
firstServerID := response.ID // Store the ID for later verification
// Now try to publish another server with the same name and version
duplicateServerDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.duplicate/test-server", // Same name
Description: "Duplicate server attempt",
Repository: model.Repository{
URL: "https://github.com/duplicate/test-server-fork",
Source: "github",
ID: "duplicate/test-server-fork",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0", // Same version
},
},
},
}
duplicateJSONData, err := json.Marshal(duplicateServerDetail)
require.NoError(t, err)
duplicateReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(duplicateJSONData))
duplicateReq.Header.Set("Content-Type", "application/json")
duplicateReq.Header.Set("Authorization", "Bearer github_token_duplicate")
duplicateRecorder := httptest.NewRecorder()
mux.ServeHTTP(duplicateRecorder, duplicateReq)
// The duplicate should fail
assert.Equal(t, http.StatusInternalServerError, duplicateRecorder.Code)
assert.Contains(t, duplicateRecorder.Body.String(), "Failed to publish server")
// Verify that only the first server was actually stored
retrievedServer, err := registryService.GetByID(firstServerID)
require.NoError(t, err)
assert.Equal(t, firstServerDetail.Name, retrievedServer.Name)
assert.Equal(t, firstServerDetail.Description, retrievedServer.Description)
})
t.Run("publish succeeds with same name but different version", func(t *testing.T) {
// Publish first version
firstVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioned/test-server",
Description: "First version of the server",
Repository: model.Repository{
URL: "https://github.com/versioned/test-server",
Source: "github",
ID: "versioned/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(firstVersionDetail)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_token_v1")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "First version should succeed")
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
firstVersionID := response.ID // Store the ID for later verification
require.NotEmpty(t, firstVersionID, "Server ID should be generated")
// Publish second version with same name but different version
secondVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioned/test-server", // Same name
Description: "Second version of the server",
Repository: model.Repository{
URL: "https://github.com/versioned/test-server",
Source: "github",
ID: "versioned/test-server",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0", // Different version
},
},
},
}
secondJSONData, err := json.Marshal(secondVersionDetail)
require.NoError(t, err)
secondReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(secondJSONData))
secondReq.Header.Set("Content-Type", "application/json")
secondReq.Header.Set("Authorization", "Bearer github_token_v2")
secondRecorder := httptest.NewRecorder()
mux.ServeHTTP(secondRecorder, secondReq)
// The second version should succeed
assert.Equal(t, http.StatusOK, secondRecorder.Code)
var secondResponse model.Server
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse)
require.NoError(t, err)
secondVersionID := secondResponse.ID // Store the ID for later verification
require.NotEmpty(t, secondVersionID, "Server ID for second version should be generated")
// Verify both versions exist
firstRetrieved, err := registryService.GetByID(firstVersionID)
require.NoError(t, err)
assert.Equal(t, "1.0.0", firstRetrieved.VersionDetail.Version)
secondRetrieved, err := registryService.GetByID(secondVersionID)
require.NoError(t, err)
assert.Equal(t, "2.0.0", secondRetrieved.VersionDetail.Version)
})
t.Run("publish fails when trying to publish older version after newer version", func(t *testing.T) {
// First, publish a newer version (2.0.0)
newerVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioning/version-order-test",
Description: "Newer version published first",
Repository: model.Repository{
URL: "https://github.com/versioning/version-order-test",
Source: "github",
ID: "versioning/version-order-test",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
},
},
}
jsonData, err := json.Marshal(newerVersionDetail)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_token_newer")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "Newer version should be published successfully")
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
newerVersionID := response.ID // Store the ID for later verification
require.NotEmpty(t, newerVersionID, "Server ID for newer version should be generated")
// Now try to publish an older version (1.0.0) of the same package
olderVersionDetail := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.versioning/version-order-test", // Same name
Description: "Older version published after newer",
Repository: model.Repository{
URL: "https://github.com/versioning/version-order-test",
Source: "github",
ID: "versioning/version-order-test",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0", // Older version
},
},
},
}
olderJSONData, err := json.Marshal(olderVersionDetail)
require.NoError(t, err)
olderReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(olderJSONData))
olderReq.Header.Set("Content-Type", "application/json")
olderReq.Header.Set("Authorization", "Bearer github_token_older")
olderRecorder := httptest.NewRecorder()
mux.ServeHTTP(olderRecorder, olderReq)
// This should fail - we shouldn't allow publishing older versions after newer ones
assert.Equal(t, http.StatusInternalServerError, olderRecorder.Code, "Publishing older version should fail")
assert.Contains(t, olderRecorder.Body.String(), "Failed to publish server", "Error message should mention version")
// Verify that only the newer version exists
newerRetrieved, err := registryService.GetByID(newerVersionID)
require.NoError(t, err)
assert.Equal(t, "2.0.0", newerRetrieved.VersionDetail.Version)
})
}
func TestPublishIntegrationEndToEnd(t *testing.T) {
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, authService)
t.Run("end-to-end publish and retrieve flow", func(t *testing.T) {
// Step 1: Get initial count of servers
initialServers, _, err := registryService.List("", 100)
require.NoError(t, err)
initialCount := len(initialServers)
// Step 2: Publish a new server
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.e2e/end-to-end-server",
Description: "End-to-end test server",
Repository: model.Repository{
URL: "https://github.com/e2e/end-to-end-server",
Source: "github",
ID: "e2e/end-to-end-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_e2e_token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
serverID := response.ID
require.Equal(t, http.StatusOK, recorder.Code)
// Step 3: Verify the count increased
updatedServers, _, err := registryService.List("", 100)
require.NoError(t, err)
assert.Equal(t, initialCount+1, len(updatedServers))
// Step 4: Verify the server can be retrieved by ID
retrievedServer, err := registryService.GetByID(serverID)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, retrievedServer.Name)
assert.Equal(t, publishReq.Description, retrievedServer.Description)
// Step 5: Verify the server appears in the list
found := false
for _, server := range updatedServers {
if server.ID == serverID {
found = true
assert.Equal(t, publishReq.Name, server.Name)
break
}
}
assert.True(t, found, "Published server should appear in the list")
})
}
func TestPublishIntegrationWithComplexPackages(t *testing.T) {
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, authService)
t.Run("publish with complex package configuration", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.complex/advanced-mcp-server",
Description: "An advanced MCP server with complex configuration",
Repository: model.Repository{
URL: "https://github.com/complex/advanced-mcp-server",
Source: "github",
ID: "complex/advanced-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "2.1.0",
},
},
Packages: []model.Package{
{
RegistryName: "npm",
Name: "@example/advanced-mcp-server",
Version: "43.1.0",
RunTimeHint: "node",
RuntimeArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "experimental-modules",
},
{
Type: model.ArgumentTypeNamed,
Name: "config",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Main configuration file",
Format: model.FormatFilePath,
IsRequired: true,
Default: "./config.json",
},
},
},
{
Type: model.ArgumentTypePositional,
Name: "mode",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Operation mode",
Format: model.FormatString,
IsRequired: false,
Default: "production",
Choices: []string{"development", "staging", "production"},
},
},
},
},
PackageArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "install-deps",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Install dependencies",
Format: model.FormatBoolean,
Default: "true",
},
},
},
},
EnvironmentVariables: []model.KeyValueInput{
{
Name: "LOG_LEVEL",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Logging level",
Format: model.FormatString,
Default: "info",
Choices: []string{"debug", "info", "warn", "error"},
},
},
},
{
Name: "API_KEY",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "API key for external service",
Format: model.FormatString,
IsRequired: true,
IsSecret: true,
},
},
},
},
},
},
Remotes: []model.Remote{
{
TransportType: "http",
URL: "http://localhost:8080/mcp",
Headers: []model.KeyValueInput{
{
Name: "API-Version",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "API Version Header",
Format: model.FormatString,
Value: "v1",
},
},
},
},
},
},
},
}
jsonData, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer github_complex_token")
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var response model.Server
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
serverID := response.ID
assert.NotEmpty(t, serverID, "Server ID should be generated")
// Verify the complex server was published correctly
publishedServer, err := registryService.GetByID(serverID)
require.NoError(t, err)
// Verify package details
require.Len(t, publishedServer.Packages, 1)
pkg := publishedServer.Packages[0]
assert.Equal(t, "npm", pkg.RegistryName)
assert.Equal(t, "@example/advanced-mcp-server", pkg.Name)
assert.Len(t, pkg.RuntimeArguments, 3)
assert.Len(t, pkg.PackageArguments, 1)
assert.Len(t, pkg.EnvironmentVariables, 2)
// Verify remotes
require.Len(t, publishedServer.Remotes, 1)
assert.Equal(t, "http", publishedServer.Remotes[0].TransportType)
assert.Len(t, publishedServer.Remotes[0].Headers, 1)
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
package v0
// Response is a generic wrapper for Huma responses
// Usage: Response[HealthBody] instead of HealthOutput
type Response[T any] struct {
Body T
}
// Example usage:
// Instead of:
// type HealthOutput struct {
// Body HealthBody
// }
//
// You could use:
// type HealthOutput = Response[HealthBody]
//
// Or directly in the handler:
// func(...) (*Response[HealthBody], error) {
// return &Response[HealthBody]{
// Body: HealthBody{...},
// }, nil
// }

View file

@ -1,22 +1,15 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"context"
"net/http"
"strconv"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/modelcontextprotocol/registry/internal/service"
)
// Response is a paginated API response
type PaginatedResponse struct {
Data []model.Server `json:"servers"`
Metadata Metadata `json:"metadata,omitempty"`
}
// Metadata contains pagination metadata
type Metadata struct {
NextCursor string `json:"next_cursor,omitempty"`
@ -24,107 +17,87 @@ type Metadata struct {
Total int `json:"total,omitempty"`
}
// ServersHandler returns a handler for listing registry items
func ServersHandler(registry service.RegistryService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// ListServersInput represents the input for listing servers
type ListServersInput struct {
Cursor string `query:"cursor" doc:"Pagination cursor (UUID)" format:"uuid" required:"false"`
Limit int `query:"limit" doc:"Number of items per page" default:"30" minimum:"1" maximum:"100"`
}
// Parse cursor and limit from query parameters
cursor := r.URL.Query().Get("cursor")
if cursor != "" {
_, err := uuid.Parse(cursor)
// ListServersBody represents the paginated server list response body
type ListServersBody struct {
Servers []model.Server `json:"servers" doc:"List of MCP servers"`
Metadata *Metadata `json:"metadata,omitempty" doc:"Pagination metadata"`
}
// ServerDetailInput represents the input for getting server details
type ServerDetailInput struct {
ID string `path:"id" doc:"Server ID (UUID)" format:"uuid"`
}
// RegisterServersEndpoints registers all server-related endpoints
func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
// List servers endpoint
huma.Register(api, huma.Operation{
OperationID: "list-servers",
Method: http.MethodGet,
Path: "/v0/servers",
Summary: "List MCP servers",
Description: "Get a paginated list of MCP servers from the registry",
Tags: []string{"servers"},
}, func(_ context.Context, input *ListServersInput) (*Response[ListServersBody], error) {
// Validate cursor if provided
if input.Cursor != "" {
_, err := uuid.Parse(input.Cursor)
if err != nil {
http.Error(w, "Invalid cursor parameter", http.StatusBadRequest)
return
return nil, huma.Error400BadRequest("Invalid cursor parameter")
}
}
limitStr := r.URL.Query().Get("limit")
// Default limit if not specified
limit := 30
// Try to parse limit from query param
if limitStr != "" {
parsedLimit, err := strconv.Atoi(limitStr)
if err != nil {
http.Error(w, "Invalid limit parameter", http.StatusBadRequest)
return
}
// Check if limit is within reasonable bounds
if parsedLimit <= 0 {
http.Error(w, "Limit must be greater than 0", http.StatusBadRequest)
return
}
// Cap maximum limit to prevent excessive queries
limit = min(parsedLimit, 100)
}
// Use the GetAll method to get paginated results
registries, nextCursor, err := registry.List(cursor, limit)
// Get paginated results
servers, nextCursor, err := registry.List(input.Cursor, input.Limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return nil, huma.Error500InternalServerError("Failed to get registry list", err)
}
// Create paginated response
response := PaginatedResponse{
Data: registries,
// Build response body
body := ListServersBody{
Servers: servers,
}
// Add metadata if there's a next cursor
if nextCursor != "" {
response.Metadata = Metadata{
body.Metadata = &Metadata{
NextCursor: nextCursor,
Count: len(registries),
Count: len(servers),
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
// ServersDetailHandler returns a handler for getting details of a specific server by ID
func ServersDetailHandler(registry service.RegistryService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract the server ID from the URL path
id := r.PathValue("id")
// Validate that the ID is a valid UUID
_, err := uuid.Parse(id)
if err != nil {
http.Error(w, "Invalid server ID format", http.StatusBadRequest)
return
}
return &Response[ListServersBody]{
Body: body,
}, nil
})
// Get server details endpoint
huma.Register(api, huma.Operation{
OperationID: "get-server",
Method: http.MethodGet,
Path: "/v0/servers/{id}",
Summary: "Get MCP server details",
Description: "Get detailed information about a specific MCP server",
Tags: []string{"servers"},
}, func(_ context.Context, input *ServerDetailInput) (*Response[model.ServerDetail], error) {
// Get the server details from the registry service
serverDetail, err := registry.GetByID(id)
serverDetail, err := registry.GetByID(input.ID)
if err != nil {
if err.Error() == "record not found" {
http.Error(w, "Server not found", http.StatusNotFound)
return
return nil, huma.Error404NotFound("Server not found")
}
http.Error(w, "Error retrieving server details", http.StatusInternalServerError)
return
return nil, huma.Error500InternalServerError("Failed to get server details", err)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(serverDetail); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
return &Response[model.ServerDetail]{
Body: *serverDetail,
}, nil
})
}

View file

@ -8,6 +8,8 @@ import (
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/google/uuid"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/model"
@ -15,10 +17,9 @@ import (
"github.com/stretchr/testify/mock"
)
func TestServersHandler(t *testing.T) {
func TestServersListEndpoint(t *testing.T) {
testCases := []struct {
name string
method string
queryParams string
setupMocks func(*MockRegistryService)
expectedStatus int
@ -27,8 +28,7 @@ func TestServersHandler(t *testing.T) {
expectedError string
}{
{
name: "successful list with default parameters",
method: http.MethodGet,
name: "successful list with default parameters",
setupMocks: func(registry *MockRegistryService) {
servers := []model.Server{
{
@ -100,8 +100,7 @@ func TestServersHandler(t *testing.T) {
},
{
name: "successful list with cursor and limit",
method: http.MethodGet,
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000" + "&limit=10",
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10",
setupMocks: func(registry *MockRegistryService) {
servers := []model.Server{
{
@ -148,62 +147,46 @@ func TestServersHandler(t *testing.T) {
},
{
name: "successful list with limit capping at 100",
method: http.MethodGet,
queryParams: "?limit=150",
setupMocks: func(registry *MockRegistryService) {
servers := []model.Server{}
registry.Mock.On("List", "", 100).Return(servers, "", nil)
},
expectedStatus: http.StatusOK,
expectedServers: []model.Server{},
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
expectedError: "validation failed",
},
{
name: "invalid cursor parameter",
method: http.MethodGet,
queryParams: "?cursor=invalid-uuid",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid cursor parameter",
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - non-numeric",
method: http.MethodGet,
queryParams: "?limit=abc",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid limit parameter",
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - zero",
method: http.MethodGet,
queryParams: "?limit=0",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Limit must be greater than 0",
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - negative",
method: http.MethodGet,
queryParams: "?limit=-5",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Limit must be greater than 0",
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "registry service error",
method: http.MethodGet,
name: "registry service error",
setupMocks: func(registry *MockRegistryService) {
registry.Mock.On("List", "", 30).Return([]model.Server{}, "", errors.New("database connection error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "database connection error",
},
{
name: "method not allowed",
method: http.MethodPost,
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusMethodNotAllowed,
expectedError: "Method not allowed",
expectedError: "Failed to get registry list",
},
}
@ -213,36 +196,40 @@ func TestServersHandler(t *testing.T) {
mockRegistry := new(MockRegistryService)
tc.setupMocks(mockRegistry)
// Create handler
handler := v0.ServersHandler(mockRegistry)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, mockRegistry)
// Create request
url := "/v0/servers" + tc.queryParams
req, err := http.NewRequestWithContext(context.Background(), tc.method, url, nil)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
// Create response recorder
rr := httptest.NewRecorder()
// Call the handler
handler.ServeHTTP(rr, req)
// Serve the request
mux.ServeHTTP(w, req)
// Check status code
assert.Equal(t, tc.expectedStatus, rr.Code)
assert.Equal(t, tc.expectedStatus, w.Code)
if tc.expectedStatus == http.StatusOK {
// Check content type
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Parse response body
var resp v0.PaginatedResponse
err = json.NewDecoder(rr.Body).Decode(&resp)
var resp struct {
Servers []model.Server `json:"servers"`
Metadata *v0.Metadata `json:"metadata,omitempty"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Check the response data
assert.Equal(t, tc.expectedServers, resp.Data)
if tc.expectedServers != nil {
assert.Equal(t, tc.expectedServers, resp.Servers)
}
// Check metadata if expected
if tc.expectedMeta != nil {
@ -253,7 +240,7 @@ func TestServersHandler(t *testing.T) {
}
} else if tc.expectedError != "" {
// Check error message for non-200 responses
assert.Contains(t, rr.Body.String(), tc.expectedError)
assert.Contains(t, w.Body.String(), tc.expectedError)
}
// Verify mock expectations
@ -262,14 +249,124 @@ func TestServersHandler(t *testing.T) {
}
}
// TestServersHandlerIntegration tests the servers list handler with actual HTTP requests
func TestServersHandlerIntegration(t *testing.T) {
func TestServersDetailEndpoint(t *testing.T) {
testCases := []struct {
name string
serverID string
setupMocks func(*MockRegistryService, string)
expectedStatus int
expectedServer *model.ServerDetail
expectedError string
}{
{
name: "successful get server detail",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
serverDetail := &model.ServerDetail{
Server: model.Server{
ID: serverID,
Name: "test-server-detail",
Description: "Test server detail",
Repository: model.Repository{
URL: "https://github.com/example/test-server-detail",
Source: "github",
ID: "example/test-server-detail",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
ReleaseDate: "2025-05-27T12:00:00Z",
IsLatest: true,
},
},
}
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
},
expectedStatus: http.StatusOK,
},
{
name: "invalid server ID format",
serverID: "invalid-uuid",
setupMocks: func(_ *MockRegistryService, _ string) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "validation failed",
},
{
name: "server not found",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("record not found"))
},
expectedStatus: http.StatusNotFound,
expectedError: "Server not found",
},
{
name: "registry service error",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("database connection error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Failed to get server details",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock registry service
mockRegistry := new(MockRegistryService)
tc.setupMocks(mockRegistry, tc.serverID)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, mockRegistry)
// Create request
url := "/v0/servers/" + tc.serverID
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
// Serve the request
mux.ServeHTTP(w, req)
// Check status code
assert.Equal(t, tc.expectedStatus, w.Code)
if tc.expectedStatus == http.StatusOK {
// Check content type
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Parse response body
var serverDetailResp model.ServerDetail
err := json.NewDecoder(w.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check that we got a valid response
assert.NotEmpty(t, serverDetailResp.ID)
assert.NotEmpty(t, serverDetailResp.Name)
} else if tc.expectedError != "" {
// Check error message for non-200 responses
assert.Contains(t, w.Body.String(), tc.expectedError)
}
// Verify mock expectations
mockRegistry.AssertExpectations(t)
})
}
}
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
func TestServersEndpointsIntegration(t *testing.T) {
// Create mock registry service
mockRegistry := new(MockRegistryService)
// Test data
serverID := uuid.New().String()
servers := []model.Server{
{
ID: "550e8400-e29b-41d4-a716-446655440004",
ID: serverID,
Name: "integration-test-server",
Description: "Integration test server",
Repository: model.Repository{
@ -285,107 +382,87 @@ func TestServersHandlerIntegration(t *testing.T) {
},
}
mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil)
// Create test server
server := httptest.NewServer(v0.ServersHandler(mockRegistry))
defer server.Close()
// Send request to the test server
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var paginatedResp v0.PaginatedResponse
err = json.NewDecoder(resp.Body).Decode(&paginatedResp)
assert.NoError(t, err)
// Check the response data
assert.Equal(t, servers, paginatedResp.Data)
assert.Empty(t, paginatedResp.Metadata.NextCursor)
// Verify mock expectations
mockRegistry.AssertExpectations(t)
}
// TestServersDetailHandlerIntegration tests the servers detail handler with actual HTTP requests
func TestServersDetailHandlerIntegration(t *testing.T) {
serverID := uuid.New().String()
// Create mock registry service
mockRegistry := new(MockRegistryService)
serverDetail := &model.ServerDetail{
Server: model.Server{
ID: serverID,
Name: "integration-test-server-detail",
Description: "Integration test server detail",
Repository: model.Repository{
URL: "https://github.com/example/integration-test-detail",
Source: "github",
ID: "example/integration-test-detail",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
ReleaseDate: "2025-05-27T12:00:00Z",
IsLatest: true,
},
},
Server: servers[0],
}
// Setup mocks
mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil)
mockRegistry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, mockRegistry)
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.SetPathValue("id", serverID)
v0.ServersDetailHandler(mockRegistry).ServeHTTP(w, r)
}))
server := httptest.NewServer(mux)
defer server.Close()
// Send request to the test server
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Test list endpoint
t.Run("list servers integration", func(t *testing.T) {
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v0/servers", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var listResp struct {
Servers []model.Server `json:"servers"`
}
err = json.NewDecoder(resp.Body).Decode(&listResp)
assert.NoError(t, err)
// Parse response body
var serverDetailResp model.ServerDetail
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check the response data
assert.Equal(t, servers, listResp.Servers)
})
// Check the response data
assert.Equal(t, *serverDetail, serverDetailResp)
// Test get server detail endpoint
t.Run("get server detail integration", func(t *testing.T) {
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v0/servers/"+serverID, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var serverDetailResp model.ServerDetail
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check the response data
assert.Equal(t, *serverDetail, serverDetailResp)
})
// Verify mock expectations
mockRegistry.AssertExpectations(t)
}
}

View file

@ -1,49 +0,0 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"net/http"
"os"
"path/filepath"
_ "github.com/swaggo/files" // Swagger files needed for embedding
httpSwagger "github.com/swaggo/http-swagger"
)
// SwaggerHandler returns a handler that serves the Swagger UI
func SwaggerHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// When accessed directly, redirect to the UI path
if r.URL.Path == "/v0/swagger" {
http.Redirect(w, r, "/v0/swagger/", http.StatusFound)
return
}
// Serve the Swagger UI
handler := httpSwagger.Handler(
httpSwagger.URL("/v0/swagger/doc.json"), // The URL to the generated Swagger JSON
httpSwagger.DeepLinking(true),
)
// Handle other Swagger UI paths
handler.ServeHTTP(w, r)
}
}
// SwaggerJSONHandler serves the Swagger specification as JSON
func SwaggerJSONHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Find the project root directory
workDir, err := os.Getwd()
if err != nil {
http.Error(w, "Unable to determine working directory", http.StatusInternalServerError)
return
}
// Path to the swagger YAML file
swaggerFilePath := filepath.Join(workDir, "internal", "docs", "swagger.yaml")
// Serve the file directly with the correct content type
http.ServeFile(w, r, swaggerFilePath)
}
}

View file

@ -4,22 +4,35 @@ package router
import (
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/service"
)
// New creates a new router with all API versions registered
func New(cfg *config.Config, registry service.RegistryService, authService auth.Service) *http.ServeMux {
mux := http.NewServeMux()
// NewHumaAPI creates a new Huma API with all routes registered
//
//nolint:ireturn // huma.API is the expected interface type for Huma APIs
func NewHumaAPI(cfg *config.Config, registry service.RegistryService, authService auth.Service, mux *http.ServeMux) huma.API {
// Create Huma API configuration
humaConfig := huma.DefaultConfig("MCP Registry API", "1.0.0")
humaConfig.Info.Description = "A community driven registry service for Model Context Protocol (MCP) servers."
// Disable $schema property in responses: https://github.com/danielgtaylor/huma/issues/230
humaConfig.CreateHooks = []func(huma.Config) huma.Config{}
// Create a new API using humago adapter for standard library
api := humago.New(mux, humaConfig)
// Register routes for all API versions
RegisterV0Routes(mux, cfg, registry, authService)
RegisterV0Routes(api, cfg, registry, authService)
// Redirect root to Swagger UI
// Add redirect from / to /docs
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/v0/swagger/index.html", http.StatusFound)
if r.URL.Path == "/" {
http.Redirect(w, r, "/docs", http.StatusMovedPermanently)
}
})
return mux
return api
}

Some files were not shown because too many files have changed in this diff Show more