diff --git a/cmd/thv/app/mcp.go b/cmd/thv/app/mcp.go index 2606118ceb..81e654d351 100644 --- a/cmd/thv/app/mcp.go +++ b/cmd/thv/app/mcp.go @@ -36,6 +36,7 @@ func newMCPCommand() *cobra.Command { // Add call subcommand cmd.AddCommand(newMCPCallCommand()) + cmd.AddCommand(newMCPCheckCommand()) // Create list command listCmd := &cobra.Command{ diff --git a/cmd/thv/app/mcp_check.go b/cmd/thv/app/mcp_check.go new file mode 100644 index 0000000000..95aa1f61ea --- /dev/null +++ b/cmd/thv/app/mcp_check.go @@ -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 +} diff --git a/cmd/thv/app/mcp_check_test.go b/cmd/thv/app/mcp_check_test.go new file mode 100644 index 0000000000..cd1f18cec6 --- /dev/null +++ b/cmd/thv/app/mcp_check_test.go @@ -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"]) +} diff --git a/docs/cli/thv_mcp.md b/docs/cli/thv_mcp.md index d77dbcbf8e..d495d230cc 100644 --- a/docs/cli/thv_mcp.md +++ b/docs/cli/thv_mcp.md @@ -33,5 +33,6 @@ The mcp command provides subcommands to interact with MCP (Model Context Protoco * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers * [thv mcp call](thv_mcp_call.md) - Invoke a tool on an MCP server +* [thv mcp check](thv_mcp_check.md) - Check MCP initialize readiness * [thv mcp list](thv_mcp_list.md) - List MCP server capabilities diff --git a/docs/cli/thv_mcp_check.md b/docs/cli/thv_mcp_check.md new file mode 100644 index 0000000000..9dd3bac5fd --- /dev/null +++ b/docs/cli/thv_mcp_check.md @@ -0,0 +1,47 @@ +--- +title: thv mcp check +hide_title: true +description: Reference for ToolHive CLI command `thv mcp check` +last_update: + author: autogenerated +slug: thv_mcp_check +mdx: + format: md +--- + +## thv mcp check + +Check MCP initialize readiness + +### Synopsis + +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. + +``` +thv mcp check [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for check + --server string MCP server URL or name from ToolHive registry (required) + --timeout duration Connection and initialize timeout (default 5s) + --transport string Transport type (auto, sse, streamable-http) (default "auto") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv mcp](thv_mcp.md) - Interact with MCP servers for debugging + diff --git a/pkg/mcp/client/client.go b/pkg/mcp/client/client.go index 0ae9c77baf..b3f8c3e006 100644 --- a/pkg/mcp/client/client.go +++ b/pkg/mcp/client/client.go @@ -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. // @@ -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 @@ -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 diff --git a/pkg/mcp/client/client_test.go b/pkg/mcp/client/client_test.go new file mode 100644 index 0000000000..9c53a94932 --- /dev/null +++ b/pkg/mcp/client/client_test.go @@ -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") +}