Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions cmd/thv/app/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func newMCPCommand() *cobra.Command {

// Add call subcommand
cmd.AddCommand(newMCPCallCommand())
cmd.AddCommand(newMCPCheckCommand())

// Create list command
listCmd := &cobra.Command{
Expand Down
87 changes: 87 additions & 0 deletions cmd/thv/app/mcp_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

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

"github.com/spf13/cobra"

thclient "github.com/stacklok/toolhive/pkg/mcp/client"
)

func newMCPCheckCommand() *cobra.Command {
var serverURL string
var transport string
var timeout time.Duration
var format string
cmd := &cobra.Command{
Use: "check",
Short: "Check MCP initialize readiness",
Long: `Connect to an MCP server, perform only the initialize handshake, and close the connection.

The command does not list or invoke tools, resources, or prompts, making it
suitable for readiness gates and CI preflight checks. It exits non-zero when
the transport or initialize handshake fails.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return mcpCheckCmdFunc(cmd, serverURL, transport, timeout, format, args)
},
}
cmd.Flags().StringVar(&serverURL, "server", "", "MCP server URL or name from ToolHive registry (required)")
cmd.Flags().StringVar(&transport, "transport", thclient.TransportAuto, "Transport type (auto, sse, streamable-http)")
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection and initialize timeout")
AddFormatFlag(cmd, &format)
_ = cmd.MarkFlagRequired("server")
cmd.PreRunE = ValidateFormat(&format)
return cmd
}

func mcpCheckCmdFunc(cmd *cobra.Command, serverURL, transport string, timeout time.Duration, format string, _ []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()

serverURL, err := resolveServerURL(ctx, serverURL)
if err != nil {
return err
}

started := time.Now()
result, err := thclient.Probe(ctx, serverURL, transport, "toolhive-cli")
if err != nil {
return fmt.Errorf("MCP readiness check failed: %w", err)
}

output := struct {
Status string `json:"status"`
LatencyMS int64 `json:"latency_ms"`
Transport string `json:"transport"`
ProtocolVersion string `json:"protocol_version"`
ServerInfo any `json:"server_info"`
Capabilities any `json:"capabilities"`
}{
Status: "ready", LatencyMS: time.Since(started).Milliseconds(),
Transport: result.Transport, ProtocolVersion: result.ProtocolVersion,
ServerInfo: result.ServerInfo, Capabilities: result.Capabilities,
}

if format == FormatJSON {
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetIndent("", " ")
if err := encoder.Encode(output); err != nil {
return fmt.Errorf("failed to write readiness result: %w", err)
}
return nil
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(),
"Status:\t%s\nTransport:\t%s\nProtocol:\t%s\nLatency:\t%dms\nServer:\t%s %s\n",
output.Status, output.Transport, output.ProtocolVersion, output.LatencyMS,
result.ServerInfo.Name, result.ServerInfo.Version); err != nil {
return fmt.Errorf("failed to write readiness result: %w", err)
}
return nil
}
54 changes: 54 additions & 0 deletions cmd/thv/app/mcp_check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stacklok/toolhive-core/mcpcompat/server"
"github.com/stacklok/toolhive/pkg/transport/types"
)

func TestMCPCheckKeepsFiveSecondDefault(t *testing.T) {
t.Parallel()

mcpCmd := newMCPCommand()
checkCmd, _, err := mcpCmd.Find([]string{"check"})
require.NoError(t, err)

timeout, err := checkCmd.Flags().GetDuration("timeout")
require.NoError(t, err)
assert.Equal(t, 5*time.Second, timeout)
}

func TestMCPCheckJSONUsesCommandWriter(t *testing.T) {
t.Parallel()

mcpServer := server.NewMCPServer("check-test", "1.2.3")
streamableServer := server.NewStreamableHTTPServer(mcpServer, server.WithEndpointPath("/mcp"))
testServer := httptest.NewServer(streamableServer)
t.Cleanup(testServer.Close)

cmd := newMCPCheckCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{
"--server", testServer.URL + "/mcp",
"--transport", string(types.TransportTypeStreamableHTTP),
"--format", FormatJSON,
})
require.NoError(t, cmd.Execute())

var result map[string]any
require.NoError(t, json.Unmarshal(output.Bytes(), &result))
assert.Equal(t, "ready", result["status"])
assert.Equal(t, string(types.TransportTypeStreamableHTTP), result["transport"])
}
1 change: 1 addition & 0 deletions docs/cli/thv_mcp.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions docs/cli/thv_mcp_check.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 55 additions & 4 deletions pkg/mcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,51 @@ import (
// transport type (try streamable-http first, then fall back to SSE).
const TransportAuto = "auto"

// ProbeResult contains the MCP metadata returned by initialize.
type ProbeResult struct {
ProtocolVersion string `json:"protocol_version"`
ServerInfo mcp.Implementation `json:"server_info"`
Capabilities mcp.ServerCapabilities `json:"capabilities"`
Transport string `json:"transport"`
}

// Probe performs only the MCP initialize handshake and closes the connection.
// It is intended for readiness checks and CI preflight; it does not enumerate
// or invoke any server capabilities.
func Probe(ctx context.Context, serverURL, transport, clientName string) (*ProbeResult, error) {
if transport == TransportAuto {
for _, candidate := range []string{string(types.TransportTypeStreamableHTTP), string(types.TransportTypeSSE)} {
result, err := probeTransport(ctx, serverURL, candidate, clientName)
if err == nil {
return result, nil
}
slog.Debug("MCP probe transport failed", "transport", candidate, "error", err)
}
return nil, fmt.Errorf("MCP initialize failed for streamable-http and SSE")
}
return probeTransport(ctx, serverURL, transport, clientName)
}

func probeTransport(ctx context.Context, serverURL, transport, clientName string) (*ProbeResult, error) {
c, err := newClient(serverURL, transport)
if err != nil {
return nil, err
}
result, err := startAndInitializeResult(ctx, c, clientName)
if closeErr := c.Close(); err == nil && closeErr != nil {
err = fmt.Errorf("close MCP client after probe: %w", closeErr)
}
if err != nil {
return nil, err
}
return &ProbeResult{
ProtocolVersion: result.ProtocolVersion,
ServerInfo: result.ServerInfo,
Capabilities: result.Capabilities,
Transport: string(resolveTransport(serverURL, transport)),
}, nil
}

// Connect creates an MCP SDK client for the given serverURL and transport,
// starts the underlying transport, and performs the MCP initialize handshake.
//
Expand Down Expand Up @@ -112,8 +157,13 @@ func connectWithAutoDetect(ctx context.Context, serverURL, clientName string) (*
// startAndInitialize starts the transport and performs the MCP initialize
// handshake using the ToolHive version.
func startAndInitialize(ctx context.Context, c *mcpclient.Client, clientName string) error {
_, err := startAndInitializeResult(ctx, c, clientName)
return err
}

func startAndInitializeResult(ctx context.Context, c *mcpclient.Client, clientName string) (*mcp.InitializeResult, error) {
if err := c.Start(ctx); err != nil {
return fmt.Errorf("start MCP client: %w", err)
return nil, fmt.Errorf("start MCP client: %w", err)
}
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
Expand All @@ -122,10 +172,11 @@ func startAndInitialize(ctx context.Context, c *mcpclient.Client, clientName str
Name: clientName,
Version: versions.GetVersionInfo().Version,
}
if _, err := c.Initialize(ctx, initReq); err != nil {
return fmt.Errorf("initialize MCP client: %w", err)
result, err := c.Initialize(ctx, initReq)
if err != nil {
return nil, fmt.Errorf("initialize MCP client: %w", err)
}
return nil
return result, nil
}

// resolveTransport determines the transport type from the user-supplied value
Expand Down
67 changes: 67 additions & 0 deletions pkg/mcp/client/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package client

import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stacklok/toolhive-core/mcpcompat/server"
"github.com/stacklok/toolhive/pkg/transport/types"
)

func TestProbePerformsOnlyInitialize(t *testing.T) {
t.Parallel()

mcpServer := server.NewMCPServer("probe-test", "1.2.3", server.WithToolCapabilities(true))
streamableServer := server.NewStreamableHTTPServer(mcpServer, server.WithEndpointPath("/mcp"))

var mu sync.Mutex
var methods []string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read MCP request body: %v", err)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))

var request struct {
Method string `json:"method"`
}
if json.Unmarshal(body, &request) == nil && request.Method != "" {
mu.Lock()
methods = append(methods, request.Method)
mu.Unlock()
}
}
streamableServer.ServeHTTP(w, r)
})
testServer := httptest.NewServer(handler)
t.Cleanup(testServer.Close)

result, err := Probe(context.Background(), testServer.URL+"/mcp",
string(types.TransportTypeStreamableHTTP), "probe-client")
require.NoError(t, err)
assert.Equal(t, "probe-test", result.ServerInfo.Name)
assert.Equal(t, "1.2.3", result.ServerInfo.Version)
assert.Equal(t, string(types.TransportTypeStreamableHTTP), result.Transport)

mu.Lock()
defer mu.Unlock()
assert.Contains(t, methods, "initialize")
assert.NotContains(t, methods, "tools/list")
assert.NotContains(t, methods, "resources/list")
assert.NotContains(t, methods, "prompts/list")
}