Skip to content
Merged
Changes from all commits
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
368 changes: 368 additions & 0 deletions test/e2e/remote_mcp_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
package e2e_test

import (
"context"
"encoding/json"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/stacklok/toolhive/test/e2e"
)

// WorkloadInfo represents a workload from thv list --format json
type WorkloadInfo struct {
Name string `json:"name"`
Package string `json:"package"`
URL string `json:"url"`
Port int `json:"port"`
ToolType string `json:"tool_type"`
TransportType string `json:"transport_type"`
ProxyMode string `json:"proxy_mode"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Labels map[string]string `json:"labels"`
Group string `json:"group"`
Remote bool `json:"remote"`
}

var _ = Describe("Remote MCP Server", Label("remote", "mcp", "e2e"), Serial, func() {
var config *e2e.TestConfig

BeforeEach(func() {
config = e2e.NewTestConfig()

// Check if thv binary is available
err := e2e.CheckTHVBinaryAvailable(config)
Expect(err).ToNot(HaveOccurred(), "thv binary should be available")
})

Describe("Running remote MCP server from registry", func() {
Context("when starting mcp-spec remote server", func() {
var serverName string

BeforeEach(func() {
serverName = generateUniqueServerName("mcp-spec-remote-test")
})

AfterEach(func() {
if config.CleanupAfter {
// Clean up the server after each test
err := e2e.StopAndRemoveMCPServer(config, serverName)
Expect(err).ToNot(HaveOccurred(), "Should be able to stop and remove server")
}
})

It("should successfully start remote server from registry [Serial]", func() {
By("Starting the mcp-spec remote MCP server")
stdout, stderr := e2e.NewTHVCommand(config, "run",
"--name", serverName,
"mcp-spec").ExpectSuccess()

// The command should indicate success
Expect(stdout+stderr).To(ContainSubstring("mcp-spec"), "Output should mention the mcp-spec server")

By("Waiting for the server to be running")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds")

By("Verifying the server appears in the list with correct attributes")
stdout, _ = e2e.NewTHVCommand(config, "list", "--format", "json").ExpectSuccess()

var workloads []WorkloadInfo
err = json.Unmarshal([]byte(stdout), &workloads)
Expect(err).ToNot(HaveOccurred(), "Should be able to parse JSON output")

// Find the server in the list
var serverInfo *WorkloadInfo
for i := range workloads {
if workloads[i].Name == serverName {
serverInfo = &workloads[i]
break
}
}

Expect(serverInfo).ToNot(BeNil(), "Server should appear in the list")
Expect(serverInfo.Status).To(Equal("running"), "Server should be in running state")
Expect(serverInfo.Remote).To(BeTrue(), "Server should be marked as remote")
Expect(serverInfo.Package).To(Equal("remote"), "Package should be 'remote'")
Expect(serverInfo.ToolType).To(Equal("remote"), "Tool type should be 'remote'")
Expect(serverInfo.TransportType).To(Equal("streamable-http"), "Transport should be streamable-http")
})

It("should verify server has remote flag set [Serial]", func() {
By("Starting the mcp-spec remote MCP server")
e2e.NewTHVCommand(config, "run",
"--name", serverName,
"mcp-spec").ExpectSuccess()

By("Waiting for the server to be running")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Verifying server has remote=true in JSON output")
stdout, _ := e2e.NewTHVCommand(config, "list", "--format", "json").ExpectSuccess()

var workloads []WorkloadInfo
err = json.Unmarshal([]byte(stdout), &workloads)
Expect(err).ToNot(HaveOccurred())

var found bool
for i := range workloads {
if workloads[i].Name == serverName {
Expect(workloads[i].Remote).To(BeTrue(), "Remote field should be true")
found = true
break
}
}
Expect(found).To(BeTrue(), "Server should be found in list")
})

It("should be accessible via the proxy endpoint [Serial]", func() {
By("Starting the mcp-spec remote MCP server")
e2e.NewTHVCommand(config, "run",
"--name", serverName,
"mcp-spec").ExpectSuccess()

By("Waiting for the server to be running")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Getting the server URL")
serverURL, err := e2e.GetMCPServerURL(config, serverName)
Expect(err).ToNot(HaveOccurred(), "Should be able to get server URL")
Expect(serverURL).To(ContainSubstring("http"), "URL should be HTTP-based")

By("Waiting for MCP server to be ready")
err = e2e.WaitForMCPServerReady(config, serverURL, "streamable-http", 60*time.Second)
Expect(err).ToNot(HaveOccurred(), "MCP server should be ready for protocol operations")
})

It("should respond to MCP protocol operations [Serial]", func() {
By("Starting the mcp-spec remote MCP server")
e2e.NewTHVCommand(config, "run",
"--name", serverName,
"mcp-spec").ExpectSuccess()

By("Waiting for the server to be running")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Getting the server URL")
serverURL, err := e2e.GetMCPServerURL(config, serverName)
Expect(err).ToNot(HaveOccurred())

By("Waiting for MCP server to be ready")
err = e2e.WaitForMCPServerReady(config, serverURL, "streamable-http", 60*time.Second)
Expect(err).ToNot(HaveOccurred(), "MCP server should be ready for protocol operations")

By("Creating MCP client and initializing connection")
mcpClient, err := e2e.NewMCPClientForStreamableHTTP(config, serverURL)
Expect(err).ToNot(HaveOccurred(), "Should be able to create MCP client")
defer mcpClient.Close()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err = mcpClient.Initialize(ctx)
Expect(err).ToNot(HaveOccurred(), "Should be able to initialize MCP connection")

By("Testing basic MCP operations")
err = mcpClient.Ping(ctx)
Expect(err).ToNot(HaveOccurred(), "Should be able to ping the server")

By("Listing available tools")
tools, err := mcpClient.ListTools(ctx)
Expect(err).ToNot(HaveOccurred(), "Should be able to list tools")
Expect(tools.Tools).ToNot(BeEmpty(), "mcp-spec server should provide tools")

By("Verifying SearchModelContextProtocol tool is available")
var foundSearchTool bool
for _, tool := range tools.Tools {
GinkgoWriter.Printf(" - %s: %s\n", tool.Name, tool.Description)
if tool.Name == "SearchModelContextProtocol" {
foundSearchTool = true
}
}
Expect(foundSearchTool).To(BeTrue(), "Should find SearchModelContextProtocol tool")
})

It("should successfully call SearchModelContextProtocol tool [Serial]", func() {
By("Starting the mcp-spec remote MCP server")
e2e.NewTHVCommand(config, "run",
"--name", serverName,
"mcp-spec").ExpectSuccess()

By("Waiting for the server to be running")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Getting the server URL")
serverURL, err := e2e.GetMCPServerURL(config, serverName)
Expect(err).ToNot(HaveOccurred())

By("Waiting for MCP server to be ready")
err = e2e.WaitForMCPServerReady(config, serverURL, "streamable-http", 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Creating MCP client and initializing connection")
mcpClient, err := e2e.NewMCPClientForStreamableHTTP(config, serverURL)
Expect(err).ToNot(HaveOccurred())
defer mcpClient.Close()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err = mcpClient.Initialize(ctx)
Expect(err).ToNot(HaveOccurred())

By("Calling SearchModelContextProtocol tool with a query")
arguments := map[string]interface{}{
"query": "transport",
}

result := mcpClient.ExpectToolCall(ctx, "SearchModelContextProtocol", arguments)
Expect(result.Content).ToNot(BeEmpty(), "Should return search results")

GinkgoWriter.Printf("Search results: %+v\n", result.Content)
})
})

Context("when managing server lifecycle", func() {
var serverName string

BeforeEach(func() {
serverName = generateUniqueServerName("mcp-spec-lifecycle-test")

// Start a server for lifecycle tests
e2e.NewTHVCommand(config, "run",
"--name", serverName,
"mcp-spec").ExpectSuccess()
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())
})

AfterEach(func() {
if config.CleanupAfter {
err := e2e.StopAndRemoveMCPServer(config, serverName)
Expect(err).ToNot(HaveOccurred(), "Should be able to stop and remove server")
}
})

It("should stop the remote server successfully [Serial]", func() {
By("Stopping the server")
stdout, _ := e2e.NewTHVCommand(config, "stop", serverName).ExpectSuccess()
Expect(stdout).To(ContainSubstring(serverName), "Output should mention the server name")

By("Verifying the server is stopped")
Eventually(func() string {
stdout, _ := e2e.NewTHVCommand(config, "list", "--all").ExpectSuccess()
return stdout
}, 10*time.Second, 1*time.Second).Should(Or(
And(ContainSubstring(serverName), ContainSubstring("stopped")),
Not(ContainSubstring(serverName)),
), "Server should be stopped or removed from list")
})

It("should restart the remote server successfully [Serial]", func() {
By("Restarting the server")
stdout, _ := e2e.NewTHVCommand(config, "restart", serverName).ExpectSuccess()
Expect(stdout).To(ContainSubstring(serverName))

By("Waiting for the server to be running again")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Verifying endpoint is accessible again")
serverURL, err := e2e.GetMCPServerURL(config, serverName)
Expect(err).ToNot(HaveOccurred())

err = e2e.WaitForMCPServerReady(config, serverURL, "streamable-http", 30*time.Second)
Expect(err).ToNot(HaveOccurred(), "Server should be ready after restart")
})

It("should view logs for remote server [Serial]", func() {
By("Getting logs for the remote server")
stdout, _ := e2e.NewTHVCommand(config, "logs", serverName).ExpectSuccess()

// Logs should exist (even if empty) and not error out
// Remote servers have proxy logs
Expect(stdout).ToNot(BeNil())
GinkgoWriter.Printf("Remote server logs:\n%s\n", stdout)
})
})
})

Describe("Running remote MCP server with custom URL", func() {
Context("when providing explicit remote URL", func() {
var serverName string

BeforeEach(func() {
serverName = generateUniqueServerName("custom-remote-test")
})

AfterEach(func() {
if config.CleanupAfter {
err := e2e.StopAndRemoveMCPServer(config, serverName)
Expect(err).ToNot(HaveOccurred(), "Should be able to stop and remove server")
}
})

It("should start remote server with explicit URL [Serial]", func() {
By("Starting remote MCP server with explicit URL")
stdout, stderr := e2e.NewTHVCommand(config, "run",
"--name", serverName,
"https://modelcontextprotocol.io/mcp").ExpectSuccess()

Expect(stdout+stderr).To(Or(
ContainSubstring("modelcontextprotocol.io"),
ContainSubstring(serverName),
), "Output should mention the URL or server name")

By("Waiting for the server to be running")
err := e2e.WaitForMCPServer(config, serverName, 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Verifying the server is marked as remote")
stdout, _ = e2e.NewTHVCommand(config, "list", "--format", "json").ExpectSuccess()

var workloads []WorkloadInfo
err = json.Unmarshal([]byte(stdout), &workloads)
Expect(err).ToNot(HaveOccurred())

var found bool
for i := range workloads {
if workloads[i].Name == serverName {
Expect(workloads[i].Remote).To(BeTrue(), "Should be marked as remote")
found = true
break
}
}
Expect(found).To(BeTrue())

By("Verifying the server is accessible")
serverURL, err := e2e.GetMCPServerURL(config, serverName)
Expect(err).ToNot(HaveOccurred())

err = e2e.WaitForMCPServerReady(config, serverURL, "streamable-http", 60*time.Second)
Expect(err).ToNot(HaveOccurred())

By("Testing MCP protocol operations")
mcpClient, err := e2e.NewMCPClientForStreamableHTTP(config, serverURL)
Expect(err).ToNot(HaveOccurred())
defer mcpClient.Close()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err = mcpClient.Initialize(ctx)
Expect(err).ToNot(HaveOccurred())

tools, err := mcpClient.ListTools(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(tools.Tools).ToNot(BeEmpty(), "Should have tools available")
})
})
})
})
Loading