diff --git a/Dockerfile b/Dockerfile index 884c1cf..5a30017 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/README.md b/README.md index 4ef1742..36685da 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/faq.md b/docs/faq.md index 57dbd75..0a75de6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -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? diff --git a/docs/server-registry-api/README.md b/docs/server-registry-api/README.md index dfe62c6..1facb31 100644 --- a/docs/server-registry-api/README.md +++ b/docs/server-registry-api/README.md @@ -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. diff --git a/go.mod b/go.mod index 821db29..b6c8f4a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 693b3fe..231508c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/handlers/v0/auth.go b/internal/api/handlers/v0/auth.go index 3abf011..bf32fcd 100644 --- a/internal/api/handlers/v0/auth.go +++ b/internal/api/handlers/v0/auth.go @@ -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 + }) +} \ No newline at end of file diff --git a/internal/api/handlers/v0/health.go b/internal/api/handlers/v0/health.go index 3dd7892..3cef98d 100644 --- a/internal/api/handlers/v0/health.go +++ b/internal/api/handlers/v0/health.go @@ -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 + }) } diff --git a/internal/api/handlers/v0/health_test.go b/internal/api/handlers/v0/health_test.go index baae604..0bf0fb3 100644 --- a/internal/api/handlers/v0/health_test.go +++ b/internal/api/handlers/v0/health_test.go @@ -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) -} +} \ No newline at end of file diff --git a/internal/api/handlers/v0/mocks_test.go b/internal/api/handlers/v0/mocks_test.go deleted file mode 100644 index 9d0197b..0000000 --- a/internal/api/handlers/v0/mocks_test.go +++ /dev/null @@ -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) -} diff --git a/internal/api/handlers/v0/ping.go b/internal/api/handlers/v0/ping.go index 6e9b0bc..6c7b9b2 100644 --- a/internal/api/handlers/v0/ping.go +++ b/internal/api/handlers/v0/ping.go @@ -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 - } +// PingBody represents the ping response body +type PingBody struct { + Pong bool `json:"pong" example:"true" doc:"Ping response"` +} - 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) - } - } +// 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 + }) } diff --git a/internal/api/handlers/v0/ping_test.go b/internal/api/handlers/v0/ping_test.go new file mode 100644 index 0000000..042e609 --- /dev/null +++ b/internal/api/handlers/v0/ping_test.go @@ -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`) +} \ No newline at end of file diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index cbbe04a..a062db8 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -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 := 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 + }) } diff --git a/internal/api/handlers/v0/publish_integration_test.go b/internal/api/handlers/v0/publish_integration_test.go new file mode 100644 index 0000000..cd290b3 --- /dev/null +++ b/internal/api/handlers/v0/publish_integration_test.go @@ -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) + }) +} \ No newline at end of file diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index 20bb930..d521dc9 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -8,314 +8,332 @@ import ( "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/auth" "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 TestPublishHandler(t *testing.T) { +// 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) + if args.Get(0) == nil { + return nil, args.Error(1) + } + 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) +} + +func TestPublishEndpoint(t *testing.T) { testCases := []struct { name string - method string - requestBody any + requestBody interface{} authHeader string setupMocks func(*MockRegistryService, *MockAuthService) expectedStatus int - expectedResponse map[string]string + expectedResponse string expectedError string }{ { - name: "successful publish with GitHub auth", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "io.github.example/test-server", - Description: "A test server", - Repository: model.Repository{ - URL: "https://github.com/example/test-server", - Source: "github", - ID: "example/test-server", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "successful publish with GitHub auth", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "io.github.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/example/test-server", + Source: "github", + ID: "example/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer github_token_123", setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { - authSvc.Mock.On("ValidateAuth", mock.Anything, model.Authentication{ + authSvc.On("ValidateAuth", mock.Anything, model.Authentication{ Method: model.AuthMethodGitHub, Token: "github_token_123", RepoRef: "io.github.example/test-server", }).Return(true, nil) - registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) - }, - expectedStatus: http.StatusCreated, - expectedResponse: map[string]string{ - "message": "Server publication successful", - "id": "test-id", + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) }, + expectedStatus: http.StatusOK, }, { - name: "successful publish with no auth (AuthMethodNone)", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id-2", - Name: "example/test-server", - Description: "A test server without auth", - Repository: model.Repository{ - URL: "https://example.com/test-server", - Source: "example", - ID: "example/test-server", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "successful publish with no auth (AuthMethodNone)", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id-2", + Name: "example/test-server", + Description: "A test server without auth", + Repository: model.Repository{ + URL: "https://example.com/test-server", + Source: "example", + ID: "example/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer some_token", setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { - authSvc.Mock.On("ValidateAuth", mock.Anything, model.Authentication{ + authSvc.On("ValidateAuth", mock.Anything, model.Authentication{ Method: model.AuthMethodNone, Token: "some_token", RepoRef: "example/test-server", }).Return(true, nil) - registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) - }, - expectedStatus: http.StatusCreated, - expectedResponse: map[string]string{ - "message": "Server publication successful", - "id": "test-id-2", + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) }, + expectedStatus: http.StatusOK, }, { - name: "method not allowed", - method: http.MethodGet, - requestBody: nil, + name: "missing authorization header", + requestBody: model.PublishRequest{}, authHeader: "", setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, - expectedStatus: http.StatusMethodNotAllowed, - expectedError: "Method not allowed", + expectedStatus: http.StatusUnprocessableEntity, + expectedError: "required header parameter is missing", }, { - name: "missing request body", - method: http.MethodPost, - requestBody: "", - authHeader: "", + name: "invalid authorization header format", + requestBody: model.PublishRequest{}, + authHeader: "InvalidFormat", setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, - expectedStatus: http.StatusBadRequest, - expectedError: "Invalid request payload:", + expectedStatus: http.StatusUnauthorized, + expectedError: "Invalid Authorization header format", }, { - name: "missing server name", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "", // Missing name - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "missing server name", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "", // Missing name + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, - authHeader: "", + authHeader: "Bearer token", setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, expectedStatus: http.StatusBadRequest, expectedError: "Name is required", }, { - name: "missing version", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "", // Missing version - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "missing version", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "", // Missing version + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, - authHeader: "", + authHeader: "Bearer token", setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, expectedStatus: http.StatusBadRequest, expectedError: "Version is required", }, { - name: "missing authorization header", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, - }, - }, - }, - authHeader: "", // Missing auth header - setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, - expectedStatus: http.StatusUnauthorized, - expectedError: "Authorization header is required", - }, - { - name: "authentication required error", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "authentication required error", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer token", setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) { - authSvc.Mock.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, auth.ErrAuthRequired) + authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, auth.ErrAuthRequired) }, expectedStatus: http.StatusUnauthorized, - expectedError: "Authentication is required for publishing", + expectedError: "Authentication failed", }, { - name: "authentication failed", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "authentication failed", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer invalid_token", setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) { - authSvc.Mock.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, nil) + authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, nil) }, expectedStatus: http.StatusUnauthorized, expectedError: "Invalid authentication credentials", }, { - name: "registry service error", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "registry service error", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer token", setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { - authSvc.Mock.On("ValidateAuth", mock.Anything, mock.Anything).Return(true, nil) - registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(assert.AnError) + authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(true, nil) + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(assert.AnError) }, expectedStatus: http.StatusInternalServerError, - expectedError: "Failed to publish server details:", + expectedError: "Failed to publish server", }, { - name: "HTML injection attack in name field", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id-html", - Name: "io.github.malicious/test-server", - Description: "A test server with HTML injection attempt", - Repository: model.Repository{ - URL: "https://github.com/malicious/test-server", - Source: "github", - ID: "malicious/test-server", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "method not allowed", + requestBody: nil, + authHeader: "", + setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, + expectedStatus: http.StatusMethodNotAllowed, + expectedError: "Method Not Allowed", + }, + { + name: "HTML injection attack in name field", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id-html", + Name: "io.github.malicious/test-server", + Description: "A test server with HTML injection attempt", + Repository: model.Repository{ + URL: "https://github.com/malicious/test-server", + Source: "github", + ID: "malicious/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer github_token_123", setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { - // The auth service should receive the escaped HTML version of the name - authSvc.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { - // Verify that the RepoRef contains escaped HTML, not the raw script tag + // The implementation should escape HTML + authSvc.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { return auth.Method == model.AuthMethodGitHub && auth.Token == "github_token_123" && - auth.RepoRef == "io.github.malicious/<script>alert('XSS')</script>test-server" + auth.RepoRef == "io.github.malicious/test-server" })).Return(true, nil) - registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) - }, - expectedStatus: http.StatusCreated, - expectedResponse: map[string]string{ - "message": "Server publication successful", - "id": "test-id-html", + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) }, + expectedStatus: http.StatusOK, }, { - name: "HTML injection attack in name field with non-GitHub prefix", - method: http.MethodPost, - requestBody: model.ServerDetail{ - Server: model.Server{ - ID: "test-id-html-non-github", - Name: "malicious.com/test-server", - Description: "A test server with HTML injection attempt (non-GitHub)", - Repository: model.Repository{ - URL: "https://malicious.com/test-server", - Source: "custom", - ID: "malicious/test-server", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + name: "HTML injection attack in name field with non-GitHub prefix", + requestBody: model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id-html-non-github", + Name: "malicious.com/test-server", + Description: "A test server with HTML injection attempt (non-GitHub)", + Repository: model.Repository{ + URL: "https://malicious.com/test-server", + Source: "custom", + ID: "malicious/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, }, authHeader: "Bearer some_token", setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { - // The auth service should receive the escaped HTML version of the name with AuthMethodNone - authSvc.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { - // Verify that the RepoRef contains escaped HTML, not the raw script tag + authSvc.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { return auth.Method == model.AuthMethodNone && auth.Token == "some_token" && - auth.RepoRef == "malicious.com/<script>alert('XSS')</script>test-server" + auth.RepoRef == "malicious.com/test-server" })).Return(true, nil) - registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) - }, - expectedStatus: http.StatusCreated, - expectedResponse: map[string]string{ - "message": "Server publication successful", - "id": "test-id-html-non-github", + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) }, + expectedStatus: http.StatusOK, }, } @@ -328,8 +346,12 @@ func TestPublishHandler(t *testing.T) { // Setup mocks tc.setupMocks(mockRegistry, mockAuthService) - // Create handler - handler := v0.PublishHandler(mockRegistry, mockAuthService) + // 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, mockRegistry, mockAuthService) // Prepare request body var requestBody []byte @@ -340,8 +362,15 @@ func TestPublishHandler(t *testing.T) { } // Create request - req, err := http.NewRequestWithContext(context.Background(), tc.method, "/publish", bytes.NewBuffer(requestBody)) + method := http.MethodPost + if tc.name == "method not allowed" { + method = http.MethodGet + } + req, err := http.NewRequestWithContext(context.Background(), method, "/v0/publish", bytes.NewBuffer(requestBody)) assert.NoError(t, err) + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } // Set auth header if provided if tc.authHeader != "" { @@ -351,23 +380,12 @@ func TestPublishHandler(t *testing.T) { // Create response recorder rr := httptest.NewRecorder() - // Call the handler - handler.ServeHTTP(rr, req) + // Call the handler through the mux + mux.ServeHTTP(rr, req) // Check status code assert.Equal(t, tc.expectedStatus, rr.Code) - if tc.expectedResponse != nil { - // Check content type for successful responses - assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) - - // Parse and verify response body - var response map[string]string - err = json.NewDecoder(rr.Body).Decode(&response) - assert.NoError(t, err) - assert.Equal(t, tc.expectedResponse, response) - } - if tc.expectedError != "" { // Check that the error message is contained in the response assert.Contains(t, rr.Body.String(), tc.expectedError) @@ -380,7 +398,7 @@ func TestPublishHandler(t *testing.T) { } } -func TestPublishHandlerBearerTokenParsing(t *testing.T) { +func TestPublishEndpointBearerTokenParsing(t *testing.T) { testCases := []struct { name string authHeader string @@ -396,11 +414,6 @@ func TestPublishHandlerBearerTokenParsing(t *testing.T) { authHeader: "bearer github_token_123", expectedToken: "github_token_123", }, - { - name: "token without Bearer prefix", - authHeader: "github_token_123", - expectedToken: "github_token_123", - }, { name: "mixed case Bearer prefix", authHeader: "BeArEr github_token_123", @@ -414,22 +427,26 @@ func TestPublishHandlerBearerTokenParsing(t *testing.T) { mockAuthService := new(MockAuthService) // Setup mock to capture the actual token passed - mockAuthService.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { + mockAuthService.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { return auth.Token == tc.expectedToken })).Return(true, nil) - mockRegistry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) + mockRegistry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) - handler := v0.PublishHandler(mockRegistry, mockAuthService) + mux := http.NewServeMux() + api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) + v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService) - serverDetail := model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + serverDetail := model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, } @@ -437,25 +454,21 @@ func TestPublishHandlerBearerTokenParsing(t *testing.T) { requestBody, err := json.Marshal(serverDetail) assert.NoError(t, err) - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodPost, - "/publish", - bytes.NewBuffer(requestBody), - ) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody)) assert.NoError(t, err) req.Header.Set("Authorization", tc.authHeader) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + mux.ServeHTTP(rr, req) - assert.Equal(t, http.StatusCreated, rr.Code) + assert.Equal(t, http.StatusOK, rr.Code) mockAuthService.AssertExpectations(t) }) } } -func TestPublishHandlerAuthMethodSelection(t *testing.T) { +func TestPublishEndpointAuthMethodSelection(t *testing.T) { testCases := []struct { name string serverName string @@ -484,22 +497,26 @@ func TestPublishHandlerAuthMethodSelection(t *testing.T) { mockAuthService := new(MockAuthService) // Setup mock to capture the auth method - mockAuthService.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { + mockAuthService.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { return auth.Method == tc.expectedAuthMethod })).Return(true, nil) - mockRegistry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) + mockRegistry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) - handler := v0.PublishHandler(mockRegistry, mockAuthService) + mux := http.NewServeMux() + api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) + v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService) - serverDetail := model.ServerDetail{ - Server: model.Server{ - ID: "test-id", - Name: tc.serverName, - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - ReleaseDate: "2025-05-25T00:00:00Z", - IsLatest: true, + serverDetail := model.PublishRequest{ + ServerDetail: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: tc.serverName, + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, }, }, } @@ -507,697 +524,16 @@ func TestPublishHandlerAuthMethodSelection(t *testing.T) { requestBody, err := json.Marshal(serverDetail) assert.NoError(t, err) - req, err := http.NewRequestWithContext( - context.Background(), - http.MethodPost, - "/publish", - bytes.NewBuffer(requestBody), - ) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody)) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer test_token") + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + mux.ServeHTTP(rr, req) - assert.Equal(t, http.StatusCreated, rr.Code) + assert.Equal(t, http.StatusOK, rr.Code) mockAuthService.AssertExpectations(t) }) } -} - -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 the publish handler - handler := v0.PublishHandler(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 - handler(recorder, req) - - // Check the response - assert.Equal(t, http.StatusCreated, recorder.Code) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Equal(t, "Server publication successful", response["message"]) - assert.NotEmpty(t, response["id"], "Server ID should be generated") - - // 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", "dummy_token") - - recorder := httptest.NewRecorder() - handler(recorder, req) - - assert.Equal(t, http.StatusCreated, recorder.Code) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Equal(t, "Server publication successful", response["message"]) - 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() - handler(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) { - serverDetail := &model.ServerDetail{ - Server: model.Server{ - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "", // Missing version - }, - }, - } - - jsonData, err := json.Marshal(serverDetail) - 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() - handler(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) { - serverDetail := &model.ServerDetail{ - Server: model.Server{ - Name: "test-server", - Description: "A test server", - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - }, - }, - } - - jsonData, err := json.Marshal(serverDetail) - 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() - handler(recorder, req) - - assert.Equal(t, http.StatusUnauthorized, recorder.Code) - assert.Contains(t, recorder.Body.String(), "Authorization header is required") - }) - - 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() - handler(recorder, req) - - assert.Equal(t, http.StatusBadRequest, recorder.Code) - assert.Contains(t, recorder.Body.String(), "Invalid") - }) - - 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() - handler(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.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() - handler(recorder, req) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Equal(t, http.StatusCreated, recorder.Code, "First publish should succeed") - - firstServerDetail.ID = response["id"] // Store the ID for later verification - - // Now try to publish another server with the same name and version - duplicateServerDetail := &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() - handler(duplicateRecorder, duplicateReq) - - // The duplicate should fail - assert.Equal(t, http.StatusBadRequest, duplicateRecorder.Code) - assert.Contains(t, duplicateRecorder.Body.String(), "Failed to publish server details") - - // Verify that only the first server was actually stored - retrievedServer, err := registryService.GetByID(firstServerDetail.ID) - 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.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() - handler(recorder, req) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - firstVersionDetail.ID = response["id"] // Store the ID for later verification - - assert.Equal(t, http.StatusCreated, recorder.Code, "First version should succeed") - require.NotEmpty(t, firstVersionDetail.ID, "Server ID should be generated") - - // Publish second version with same name but different version - secondVersionDetail := &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() - handler(secondRecorder, secondReq) - - var secondResponse map[string]string - err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse) - require.NoError(t, err) - secondVersionDetail.ID = secondResponse["id"] // Store the ID for later verification - - // The second version should succeed - assert.Equal(t, http.StatusCreated, secondRecorder.Code) - require.NotEmpty(t, secondVersionDetail.ID, "Server ID for second version should be generated") - - // Verify both versions exist - firstRetrieved, err := registryService.GetByID(firstVersionDetail.ID) - require.NoError(t, err) - assert.Equal(t, "1.0.0", firstRetrieved.VersionDetail.Version) - - secondRetrieved, err := registryService.GetByID(secondVersionDetail.ID) - 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.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() - handler(recorder, req) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - newerVersionDetail.ID = response["id"] // Store the ID for later verification - - assert.Equal(t, http.StatusCreated, recorder.Code, "Newer version should be published successfully") - require.NotEmpty(t, newerVersionDetail.ID, "Server ID for newer version should be generated") - - // Now try to publish an older version (1.0.0) of the same package - olderVersionDetail := &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() - handler(olderRecorder, olderReq) - - // This should fail - we shouldn't allow publishing older versions after newer ones - assert.Equal(t, http.StatusBadRequest, olderRecorder.Code, "Publishing older version should fail") - assert.Contains(t, olderRecorder.Body.String(), "version", "Error message should mention version") - - // Verify that only the newer version exists - newerRetrieved, err := registryService.GetByID(newerVersionDetail.ID) - require.NoError(t, err) - assert.Equal(t, "2.0.0", newerRetrieved.VersionDetail.Version) - - // Verify the older version was not stored - _, err = registryService.GetByID(olderVersionDetail.ID) - assert.Error(t, err, "Older version should not have been stored") - }) -} - -func TestPublishIntegrationEndToEnd(t *testing.T) { - registryService := service.NewFakeRegistryService() - authService := &MockAuthService{} - authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil) - handler := v0.PublishHandler(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 - 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(serverDetail) - 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() - handler(recorder, req) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - serverDetail.ID = response["id"] // Store the ID for later verification - - require.Equal(t, http.StatusCreated, 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(serverDetail.ID) - require.NoError(t, err) - assert.Equal(t, serverDetail.Name, retrievedServer.Name) - assert.Equal(t, serverDetail.Description, retrievedServer.Description) - - // Step 5: Verify the server appears in the list - found := false - for _, server := range updatedServers { - if server.ID == serverDetail.ID { - found = true - assert.Equal(t, serverDetail.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) - handler := v0.PublishHandler(registryService, authService) - - t.Run("publish with complex package configuration", func(t *testing.T) { - 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.Input{ - { - Description: "API Version Header", - Format: model.FormatString, - Value: "v1", - }, - }, - }, - }, - } - - jsonData, err := json.Marshal(serverDetail) - 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() - handler(recorder, req) - - assert.Equal(t, http.StatusCreated, recorder.Code) - - var response map[string]string - err = json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - - serverDetail.ID = response["id"] // Store the ID for later verification - assert.Equal(t, "Server publication successful", response["message"]) - assert.NotEmpty(t, response["id"], "Server ID should be generated") - - // Verify the complex server was published correctly - publishedServer, err := registryService.GetByID(serverDetail.ID) - 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) - }) -} +} \ No newline at end of file diff --git a/internal/api/handlers/v0/response.go b/internal/api/handlers/v0/response.go new file mode 100644 index 0000000..1169400 --- /dev/null +++ b/internal/api/handlers/v0/response.go @@ -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 +// } \ No newline at end of file diff --git a/internal/api/handlers/v0/servers.go b/internal/api/handlers/v0/servers.go index e332941..42d85f0 100644 --- a/internal/api/handlers/v0/servers.go +++ b/internal/api/handlers/v0/servers.go @@ -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 + }) +} \ No newline at end of file diff --git a/internal/api/handlers/v0/servers_test.go b/internal/api/handlers/v0/servers_test.go index c0dd7f6..e5bce83 100644 --- a/internal/api/handlers/v0/servers_test.go +++ b/internal/api/handlers/v0/servers_test.go @@ -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) -} +} \ No newline at end of file diff --git a/internal/api/handlers/v0/swagger.go b/internal/api/handlers/v0/swagger.go deleted file mode 100644 index 6a368f8..0000000 --- a/internal/api/handlers/v0/swagger.go +++ /dev/null @@ -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) - } -} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index dad7264..f62a4ad 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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 } diff --git a/internal/api/router/v0.go b/internal/api/router/v0.go index 6d465f9..e03608a 100644 --- a/internal/api/router/v0.go +++ b/internal/api/router/v0.go @@ -2,26 +2,19 @@ package router import ( - "net/http" - + "github.com/danielgtaylor/huma/v2" v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/service" ) -// RegisterV0Routes registers all v0 API routes to the provided router func RegisterV0Routes( - mux *http.ServeMux, cfg *config.Config, registry service.RegistryService, authService auth.Service, + api huma.API, cfg *config.Config, registry service.RegistryService, authService auth.Service, ) { - // Register v0 endpoints - mux.HandleFunc("/v0/health", v0.HealthHandler(cfg)) - mux.HandleFunc("/v0/servers", v0.ServersHandler(registry)) - mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry)) - mux.HandleFunc("/v0/ping", v0.PingHandler(cfg)) - mux.HandleFunc("/v0/publish", v0.PublishHandler(registry, authService)) - - // Register Swagger UI routes - mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler()) - mux.HandleFunc("/v0/swagger/doc.json", v0.SwaggerJSONHandler()) + v0.RegisterHealthEndpoint(api, cfg) + v0.RegisterPingEndpoint(api) + v0.RegisterServersEndpoints(api, registry) + // v0.RegisterAuthEndpoints(api, authService) + v0.RegisterPublishEndpoint(api, registry, authService) } diff --git a/internal/api/server.go b/internal/api/server.go index c92a1a5..2792ecb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/danielgtaylor/huma/v2" "github.com/modelcontextprotocol/registry/internal/api/router" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" @@ -17,20 +18,21 @@ type Server struct { config *config.Config registry service.RegistryService authService auth.Service - router *http.ServeMux + humaAPI huma.API server *http.Server } // NewServer creates a new HTTP server func NewServer(cfg *config.Config, registryService service.RegistryService, authService auth.Service) *Server { - // Create router with all API versions registered - mux := router.New(cfg, registryService, authService) + // Create HTTP mux and Huma API + mux := http.NewServeMux() + api := router.NewHumaAPI(cfg, registryService, authService, mux) server := &Server{ config: cfg, registry: registryService, authService: authService, - router: mux, + humaAPI: api, server: &http.Server{ Addr: cfg.ServerAddress, Handler: mux, diff --git a/internal/database/memory.go b/internal/database/memory.go index 7a33415..2efcb64 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -234,10 +234,13 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetai return ErrInvalidInput } - // Generate a new ID for the server detail + // Always generate a new UUID for the ID serverDetail.ID = uuid.New().String() serverDetail.VersionDetail.IsLatest = true // Assume the new version is the latest - serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) + // Only set ReleaseDate if it's not already provided + if serverDetail.VersionDetail.ReleaseDate == "" { + serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) + } // Store a copy of the entire ServerDetail serverDetailCopy := *serverDetail db.entries[serverDetail.ID] = &serverDetailCopy diff --git a/internal/docs/publish_swagger.yaml b/internal/docs/publish_swagger.yaml deleted file mode 100644 index 00d6079..0000000 --- a/internal/docs/publish_swagger.yaml +++ /dev/null @@ -1,65 +0,0 @@ -/v0/publish: - post: - tags: - - servers - summary: Publish a server to the registry - description: Adds a new server to the MCP registry with authentication - operationId: publishServer - security: - - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - server_detail: - $ref: '#/components/schemas/ServerDetail' - repo_ref: - type: string - description: Repository reference used for authentication (defaults to server name if not provided) - responses: - '201': - description: Server published successfully - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Server publication successful - id: - type: string - example: 1234567890abcdef12345678 - '400': - description: Invalid request - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Name is required - '401': - description: Authentication failed - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Authentication is required for publishing - '500': - description: Server error - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Failed to publish server details diff --git a/internal/docs/swagger.yaml b/internal/docs/swagger.yaml deleted file mode 100644 index 3f8a587..0000000 --- a/internal/docs/swagger.yaml +++ /dev/null @@ -1,534 +0,0 @@ -openapi: 3.0.0 -info: - title: Model Context Protocol Registry API - description: API for the Model Context Protocol Registry - version: "0.1.0" - -servers: - - url: / - description: Default server - -tags: - - name: health - description: Health checking operations - - name: servers - description: Server registry operations - -paths: - /v0/health: - get: - tags: - - health - summary: Health check endpoint - description: Returns the health status of the API - operationId: healthCheck - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: ok - github_client_id: - type: string - example: "your_github_client_id" - /v0/ping: - get: - tags: - - health - summary: API version check - description: Returns the API version and status - operationId: pingCheck - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: ok - version: - type: string - example: "0.1.0" - '405': - description: Method not allowed - - /v0/servers: - get: - tags: - - servers - summary: List registered servers - description: Returns a paginated list of registered servers - operationId: listServers - parameters: - - name: cursor - in: query - description: Pagination cursor (UUID) - required: false - schema: - type: string - format: uuid - - name: limit - in: query - description: Maximum number of items to return (1-100, default 30) - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - default: 30 - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - properties: - servers: - type: array - items: - $ref: '#/components/schemas/Server' - metadata: - $ref: '#/components/schemas/PaginationMetadata' - example: - servers: - - id: "123e4567-e89b-12d3-a456-426614174000" - name: "io.github.modelcontextprotocol/filesystem" - description: "Node.js server implementing Model Context Protocol (MCP) for filesystem operations." - status: "active" - repository: - url: "https://github.com/modelcontextprotocol/servers" - source: "github" - id: "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" - version_detail: - version: "1.0.2" - release_date: "2023-06-15T10:30:00Z" - is_latest: true - created_at: "2025-05-17T17:34:22.912Z" - updated_at: "2025-05-17T17:34:22.912Z" - metadata: - next_cursor: "123e4567-e89b-12d3-a456-426614174000" - count: 30 - '400': - description: Invalid cursor or limit parameter - '405': - description: Method not allowed - - /v0/publish: - post: - tags: - - servers - summary: Publish a server to the registry - description: Adds a new server to the MCP registry with authentication - operationId: publishServer - security: - - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - server_detail: - $ref: '#/components/schemas/ServerDetail' - repo_ref: - type: string - description: Repository reference used for authentication (defaults to server name if not provided) - example: - server_detail: - description: "" - name: "io.github./" - packages: - - registry_name: "npm" - name: "@/" - version: "0.2.23" - package_arguments: - - description: "Specify services and permissions." - is_required: true - format: "string" - value: "-s" - default: "-s" - type: "positional" - value_hint: "-s" - environment_variables: - - description: "API Key to access the server" - name: "API_KEY" - - registry_name: "docker" - name: "@/-cli" - version: "0.123.223" - runtime_hint: "docker" - runtime_arguments: - - description: "Specify services and permissions." - is_required: true - format: "string" - value: "--mount" - default: "--mount" - type: "positional" - value_hint: "--mount" - environment_variables: - - description: "API Key to access the server" - name: "API_KEY" - repository: - url: "https://github.com//" - source: "github" - version_detail: - version: "0.0.1-" - repo_ref: "optional-repository-reference" - responses: - '201': - description: Server published successfully - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Server publication successful - id: - type: string - example: 1234567890abcdef12345678 - '400': - description: Invalid request - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Name is required - '401': - description: Authentication failed - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Authentication is required for publishing - '405': - description: Method not allowed - '500': - description: Server error - content: - application/json: - schema: - type: object - properties: - error: - type: string - example: Failed to publish server details - - /v0/servers/{id}: - get: - tags: - - servers - summary: Get server details - description: Returns details of a specific server by ID - operationId: getServerDetails - parameters: - - name: id - in: path - description: Server ID (UUID) - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/ServerDetail' - example: - id: "01129bff-3d65-4e3d-8e82-6f2f269f818c" - name: "io.github.gongrzhe/redis-mcp-server" - description: "A Redis MCP server implementation for interacting with Redis databases. This server enables LLMs to interact with Redis key-value stores through a set of standardized tools." - status: "active" - repository: - url: "https://github.com/GongRzhe/REDIS-MCP-Server" - source: "github" - id: "907849235" - version_detail: - version: "0.0.1-seed" - release_date: "2025-05-16T19:13:21Z" - is_latest: true - created_at: "2025-05-17T17:34:22.912Z" - updated_at: "2025-05-17T17:34:22.912Z" - packages: - - registry_name: "docker" - name: "@gongrzhe/server-redis-mcp" - version: "1.0.0" - package_arguments: - - description: "Docker image to run" - is_required: true - format: "string" - value: "mcp/redis" - default: "mcp/redis" - type: "positional" - value_hint: "mcp/redis" - - description: "Redis server connection string" - is_required: true - format: "string" - value: "redis://host.docker.internal:6379" - default: "redis://host.docker.internal:6379" - type: "positional" - value_hint: "host.docker.internal:6379" - '400': - description: Invalid ID format - '404': - description: Server not found - '405': - description: Method not allowed - -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"" - - schemas: - Server: - type: object - required: - - name - - description - - version_detail - properties: - id: - type: string - format: uuid - example: "123e4567-e89b-12d3-a456-426614174000" - name: - type: string - description: "Server name/identifier" - example: "io.github.modelcontextprotocol/filesystem" - description: - type: string - description: "Human-readable description of the server's functionality" - example: "Node.js server implementing Model Context Protocol (MCP) for filesystem operations." - status: - type: string - enum: [active, deprecated] - default: active - description: "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage." - example: "active" - repository: - $ref: '#/components/schemas/Repository' - version_detail: - $ref: '#/components/schemas/VersionDetail' - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - - PaginationMetadata: - type: object - properties: - next_cursor: - type: string - format: uuid - example: "123e4567-e89b-12d3-a456-426614174000" - count: - type: integer - example: 30 - - Repository: - type: object - required: - - url - - source - - id - properties: - url: - type: string - format: uri - example: "https://github.com/modelcontextprotocol/servers" - source: - type: string - example: "github" - id: - type: string - example: "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" - - VersionDetail: - type: object - required: - - version - - release_date - - is_latest - properties: - version: - type: string - example: "1.0.2" - description: Equivalent of Implementation.version in MCP specification. - release_date: - type: string - format: date-time - example: "2023-06-15T10:30:00Z" - description: Datetime that the MCP server version was published to the registry. - is_latest: - type: boolean - example: true - description: Whether the MCP server version is the latest version available in the registry. - - ServerDetail: - description: Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration. - allOf: - - $ref: '#/components/schemas/Server' - - type: object - properties: - packages: - type: array - items: - $ref: '#/components/schemas/Package' - remotes: - type: array - items: - $ref: '#/components/schemas/Remote' - - Package: - type: object - required: - - registry_name - - name - - version - properties: - registry_name: - type: string - description: Package registry type - example: "npm" - name: - type: string - description: Package name in the registry - example: "io.modelcontextprotocol/filesystem" - version: - type: string - description: Package version - example: "1.0.2" - runtime_hint: - type: string - description: A hint to help clients determine the appropriate runtime for the package - example: "docker" - runtime_arguments: - type: array - description: A list of arguments to be passed to the package's runtime command - items: - $ref: '#/components/schemas/Argument' - package_arguments: - type: array - description: A list of arguments to be passed to the package's binary - items: - $ref: '#/components/schemas/Argument' - environment_variables: - type: array - description: Environment variables to be set when running the package - items: - $ref: '#/components/schemas/KeyValueInput' - - Input: - type: object - properties: - description: - description: A description of the input - type: string - is_required: - type: boolean - default: false - format: - type: string - enum: [string, number, boolean, filepath] - default: string - value: - type: string - is_secret: - type: boolean - default: false - default: - type: string - choices: - type: array - items: - type: string - - InputWithVariables: - allOf: - - $ref: '#/components/schemas/Input' - - type: object - properties: - variables: - type: object - additionalProperties: - $ref: '#/components/schemas/Input' - - KeyValueInput: - allOf: - - $ref: '#/components/schemas/InputWithVariables' - - type: object - required: - - name - properties: - name: - type: string - description: Name of the header or environment variable - example: "API_KEY" - - Argument: - allOf: - - $ref: '#/components/schemas/InputWithVariables' - - type: object - required: - - type - properties: - type: - type: string - enum: [positional, named] - example: "positional" - name: - type: string - description: The flag name (for named arguments) - example: "--port" - is_repeated: - type: boolean - default: false - value_hint: - type: string - example: "file_path" - - Remote: - type: object - required: - - transport_type - - url - properties: - transport_type: - type: string - enum: [streamable, sse] - description: Transport protocol type - example: "sse" - url: - type: string - format: uri - description: Remote server URL - example: "https://mcp-fs.example.com/sse" - headers: - type: array - description: HTTP headers to include - items: - $ref: '#/components/schemas/KeyValueInput' diff --git a/internal/model/model.go b/internal/model/model.go index 293deab..294c7e4 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -57,17 +57,14 @@ const ( FormatFilePath Format = "file_path" ) -// UserInput represents a user input as defined in the spec type Input struct { - Description string `json:"description,omitempty" bson:"description,omitempty"` - IsRequired bool `json:"is_required,omitempty" bson:"is_required,omitempty"` - Format Format `json:"format,omitempty" bson:"format,omitempty"` - Value string `json:"value,omitempty" bson:"value,omitempty"` - IsSecret bool `json:"is_secret,omitempty" bson:"is_secret,omitempty"` - Default string `json:"default,omitempty" bson:"default,omitempty"` - Choices []string `json:"choices,omitempty" bson:"choices,omitempty"` - Template string `json:"template,omitempty" bson:"template,omitempty"` - Properties map[string]Input `json:"properties,omitempty" bson:"properties,omitempty"` + Description string `json:"description,omitempty" bson:"description,omitempty"` + IsRequired bool `json:"is_required,omitempty" bson:"is_required,omitempty"` + Format Format `json:"format,omitempty" bson:"format,omitempty"` + Value string `json:"value,omitempty" bson:"value,omitempty"` + IsSecret bool `json:"is_secret,omitempty" bson:"is_secret,omitempty"` + Default string `json:"default,omitempty" bson:"default,omitempty"` + Choices []string `json:"choices,omitempty" bson:"choices,omitempty"` } type InputWithVariables struct { @@ -107,25 +104,25 @@ type Package struct { // Remote represents a remote connection endpoint type Remote struct { - TransportType string `json:"transport_type" bson:"transport_type"` - URL string `json:"url" bson:"url"` - Headers []Input `json:"headers,omitempty" bson:"headers,omitempty"` + TransportType string `json:"transport_type" bson:"transport_type"` + URL string `json:"url" bson:"url"` + Headers []KeyValueInput `json:"headers,omitempty" bson:"headers,omitempty"` } // VersionDetail represents the version details of a server type VersionDetail struct { Version string `json:"version" bson:"version"` - ReleaseDate string `json:"release_date" bson:"release_date"` - IsLatest bool `json:"is_latest" bson:"is_latest"` + ReleaseDate string `json:"release_date,omitempty" bson:"release_date"` + IsLatest bool `json:"is_latest,omitempty" bson:"is_latest"` } // Server represents a basic server information as defined in the spec type Server struct { - ID string `json:"id" bson:"id"` + ID string `json:"id,omitempty" bson:"id"` Name string `json:"name" bson:"name"` Description string `json:"description" bson:"description"` Status ServerStatus `json:"status,omitempty" bson:"status,omitempty"` - Repository Repository `json:"repository" bson:"repository"` + Repository Repository `json:"repository,omitempty" bson:"repository"` VersionDetail VersionDetail `json:"version_detail" bson:"version_detail"` }