From 41c58b98bc0793948b763dc5ddc20675afcb925b Mon Sep 17 00:00:00 2001 From: XD Date: Wed, 19 Aug 2026 14:12:18 +0800 Subject: [PATCH 1/2] Add MCP initialize readiness check Expose a protocol-level preflight for Agent harnesses and CI. The check performs only transport setup and the MCP initialize handshake, then reports negotiated metadata without enumerating or invoking capabilities. Signed-off-by: XD --- cmd/thv/app/mcp.go | 1 + cmd/thv/app/mcp_check.go | 78 +++++++++++++++++++++++++++++++++++++++ docs/cli/thv_mcp_check.md | 17 +++++++++ pkg/mcp/client/client.go | 59 +++++++++++++++++++++++++++-- 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 cmd/thv/app/mcp_check.go create mode 100644 docs/cli/thv_mcp_check.md 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..667b394c98 --- /dev/null +++ b/cmd/thv/app/mcp_check.go @@ -0,0 +1,78 @@ +// 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 { + 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: mcpCheckCmdFunc, + } + cmd.Flags().StringVar(&mcpServerURL, "server", "", "MCP server URL or name from ToolHive registry (required)") + cmd.Flags().StringVar(&mcpTransport, "transport", thclient.TransportAuto, "Transport type (auto, sse, streamable-http)") + cmd.Flags().DurationVar(&mcpTimeout, "timeout", 5*time.Second, "Connection and initialize timeout") + AddFormatFlag(cmd, &mcpFormat) + _ = cmd.MarkFlagRequired("server") + cmd.PreRunE = ValidateFormat(&mcpFormat) + return cmd +} + +func mcpCheckCmdFunc(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), mcpTimeout) + defer cancel() + + serverURL, err := resolveServerURL(ctx, mcpServerURL) + if err != nil { + return err + } + + started := time.Now() + result, err := thclient.Probe(ctx, serverURL, mcpTransport, "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 mcpFormat == FormatJSON { + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal readiness result: %w", err) + } + fmt.Println(string(data)) + return nil + } + fmt.Printf("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) + return nil +} diff --git a/docs/cli/thv_mcp_check.md b/docs/cli/thv_mcp_check.md new file mode 100644 index 0000000000..e502515729 --- /dev/null +++ b/docs/cli/thv_mcp_check.md @@ -0,0 +1,17 @@ +--- +title: thv mcp check +--- + + + +# thv mcp check + +Check MCP initialize readiness without enumerating or invoking capabilities. + +```text +thv mcp check --server [flags] +``` + +The command exits successfully only when the transport starts and the MCP +`initialize` handshake completes. Use `--format json` for CI and Agent +harnesses. 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 From 95889a83b0b451b48abee9183523d348199cca1b Mon Sep 17 00:00:00 2001 From: XD Date: Wed, 19 Aug 2026 16:24:03 +0800 Subject: [PATCH 2/2] Harden MCP readiness check output Keep mcp check flags isolated from sibling commands and route text and JSON results through Cobra output writers. Add real initialize-only and CLI regression coverage, then regenerate the command documentation. Signed-off-by: XD --- cmd/thv/app/mcp_check.go | 43 +++++++++++++--------- cmd/thv/app/mcp_check_test.go | 54 ++++++++++++++++++++++++++++ docs/cli/thv_mcp.md | 1 + docs/cli/thv_mcp_check.md | 46 +++++++++++++++++++----- pkg/mcp/client/client_test.go | 67 +++++++++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 cmd/thv/app/mcp_check_test.go create mode 100644 pkg/mcp/client/client_test.go diff --git a/cmd/thv/app/mcp_check.go b/cmd/thv/app/mcp_check.go index 667b394c98..95aa1f61ea 100644 --- a/cmd/thv/app/mcp_check.go +++ b/cmd/thv/app/mcp_check.go @@ -15,6 +15,10 @@ import ( ) 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", @@ -24,28 +28,30 @@ 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: mcpCheckCmdFunc, + RunE: func(cmd *cobra.Command, args []string) error { + return mcpCheckCmdFunc(cmd, serverURL, transport, timeout, format, args) + }, } - cmd.Flags().StringVar(&mcpServerURL, "server", "", "MCP server URL or name from ToolHive registry (required)") - cmd.Flags().StringVar(&mcpTransport, "transport", thclient.TransportAuto, "Transport type (auto, sse, streamable-http)") - cmd.Flags().DurationVar(&mcpTimeout, "timeout", 5*time.Second, "Connection and initialize timeout") - AddFormatFlag(cmd, &mcpFormat) + 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(&mcpFormat) + cmd.PreRunE = ValidateFormat(&format) return cmd } -func mcpCheckCmdFunc(cmd *cobra.Command, _ []string) error { - ctx, cancel := context.WithTimeout(cmd.Context(), mcpTimeout) +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, mcpServerURL) + serverURL, err := resolveServerURL(ctx, serverURL) if err != nil { return err } started := time.Now() - result, err := thclient.Probe(ctx, serverURL, mcpTransport, "toolhive-cli") + result, err := thclient.Probe(ctx, serverURL, transport, "toolhive-cli") if err != nil { return fmt.Errorf("MCP readiness check failed: %w", err) } @@ -63,16 +69,19 @@ func mcpCheckCmdFunc(cmd *cobra.Command, _ []string) error { ServerInfo: result.ServerInfo, Capabilities: result.Capabilities, } - if mcpFormat == FormatJSON { - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal readiness result: %w", err) + 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) } - fmt.Println(string(data)) return nil } - fmt.Printf("Status:\t%s\nTransport:\t%s\nProtocol:\t%s\nLatency:\t%dms\nServer:\t%s %s\n", + 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) + 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 index e502515729..9dd3bac5fd 100644 --- a/docs/cli/thv_mcp_check.md +++ b/docs/cli/thv_mcp_check.md @@ -1,17 +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 -# thv mcp check +Check MCP initialize readiness -Check MCP initialize readiness without enumerating or invoking capabilities. +### Synopsis -```text -thv mcp check --server [flags] +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") ``` -The command exits successfully only when the transport starts and the MCP -`initialize` handshake completes. Use `--format json` for CI and Agent -harnesses. +### 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_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") +}