Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions pkg/custom_mcp_server/handler/custom_mcp_server_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type CustomMcpServerHandler interface {
Update(c *gin.Context)
Delete(c *gin.Context)
Test(c *gin.Context)
TestConnection(c *gin.Context)
}

// customMcpServerHandler implements the CustomMcpServerHandler interface.
Expand Down Expand Up @@ -79,6 +80,10 @@ func (h *customMcpServerHandler) RegisterRoutesMiddleware(router gin.IRouter) {
customMcpServers.GET("/:id/test",
permissionMiddleware.RequirePermission("ai_custom_mcp_servers", "read"),
h.Test)
// EVO-1739: stateless test-before-save (validates url/headers typed in the wizard).
customMcpServers.POST("/test-connection",
permissionMiddleware.RequirePermission("ai_custom_mcp_servers", "read"),
h.TestConnection)
}
}

Expand Down Expand Up @@ -309,3 +314,24 @@ func (h *customMcpServerHandler) Test(c *gin.Context) {

response.SuccessResponse(c, customMcpServer, "Custom MCP server test completed successfully", http.StatusOK)
}

// TestConnection tests an UNSAVED MCP server's url/headers (test-before-save). EVO-1739.
func (h *customMcpServerHandler) TestConnection(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.ValidationErrorResponse(c, err)
return
}

Comment on lines +321 to +330

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Consider adding stricter validation/constraints on the URL to avoid SSRF-style misuse.

Because this endpoint forwards a client-provided URL directly to TestConnection, it may allow SSRF (e.g., internal services, metadata endpoints), depending on that implementation. Please consider tightening input by restricting schemes (e.g., HTTPS only), adding host/IP allow/deny lists, and/or reusing any existing URL validation used for persisted MCP servers so this “test” path isn’t more permissive than the saved one.

testResult, err := h.customMcpServerService.TestConnection(c.Request.Context(), req.URL, req.Headers)
if err != nil {
code, message, httpCode := errors.HandleError(err)
response.ErrorResponse(c, code, message, nil, httpCode)
return
}

response.SuccessResponse(c, gin.H{"test_result": testResult}, "Custom MCP server test completed successfully", http.StatusOK)
}
9 changes: 9 additions & 0 deletions pkg/custom_mcp_server/service/custom_mcp_server_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type CustomMcpServerService interface {
Delete(ctx context.Context, id uuid.UUID) (bool, error)
GetByAgentConfig(ctx context.Context, serverIDs []uuid.UUID) ([]*model.CustomMcpServer, error)
Test(ctx context.Context, id uuid.UUID) (*model.CustomMcpServerTestResponse, error)
// EVO-1739: stateless test of an UNSAVED server's url/headers, so the wizard can
// "test before save". Reuses the same processor MCP handshake as Test.
TestConnection(ctx context.Context, url string, headers map[string]string) (*model.TestResult, error)
}

type customMcpServerService struct {
Expand Down Expand Up @@ -212,6 +215,12 @@ func (s *customMcpServerService) Test(ctx context.Context, id uuid.UUID) (*model
}, nil
}

// TestConnection runs the MCP handshake against arbitrary url/headers without a saved
// server — powers the wizard's "test before save" (EVO-1739).
func (s *customMcpServerService) TestConnection(ctx context.Context, url string, headers map[string]string) (*model.TestResult, error) {
return s.testConnection(ctx, url, headers)
}

// EVO-2139: delegate the MCP connection test to the processor, which owns
// the real handshake (POST JSON-RPC 2.0 `initialize`) via Google ADK's
// MCPToolset. The previous implementation did a raw `GET /health` from Go
Expand Down
19 changes: 19 additions & 0 deletions pkg/custom_mcp_server/service/custom_mcp_server_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,25 @@ func TestTestConnection_DelegatesToProcessor(t *testing.T) {
// EVO-2139: propagate X-Evo-Tenant-Id on the test call too, so the
// processor's runtime_context middleware (PY-1) can authorize it.
// Paridade com TestDiscoverTools_PropagatesTenantHeader_WhenBound.
// EVO-1739: the public TestConnection wrapper (test-before-save) must delegate to the
// same processor handshake and surface the TestResult unchanged.
func TestTestConnection_PublicWrapper_DelegatesAndReturnsResult(t *testing.T) {
cs := newCaptureTestServer(t, `{"success":true,"status_code":200,"response_time":0.1,"url_tested":"https://mcp.example/mcp","message":"ok","tools_count":2}`)
svc := newServiceForTest(cs.URL)
ctx := context.WithValue(context.Background(), "token", "tok-abc")

result, err := svc.TestConnection(ctx, "https://mcp.example/mcp", map[string]string{})
if err != nil {
t.Fatalf("TestConnection: %v", err)
}
if want := "/api/v1/custom-mcp-servers/test-connection"; cs.gotPath != want {
t.Fatalf("path: got %q want %q", cs.gotPath, want)
}
if !result.Success || result.StatusCode != http.StatusOK {
t.Fatalf("want success 200, got success=%v code=%d", result.Success, result.StatusCode)
}
}

func TestTestConnection_PropagatesTenantHeader_WhenBound(t *testing.T) {
cs := newCaptureTestServer(t, `{"success":true,"status_code":200,"tools_count":0}`)
svc := newServiceForTest(cs.URL)
Expand Down
Loading