Skip to content
Merged
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
38 changes: 34 additions & 4 deletions pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ import (
// ErrContainerExitedRestartNeeded is returned when a container exits and needs to be restarted
var ErrContainerExitedRestartNeeded = errors.New("container exited, restart needed")

// probeProtocolVersion is the MCP protocol revision the readiness probe advertises
// in the JSON-RPC params.protocolVersion of its initialize handshake.
//
// It is deliberately pinned to 2025-11-25 — the current stable revision and the
// newest one that still defines the `initialize` method — rather than tracking
// mcp.LATEST_PROTOCOL_VERSION. The upcoming 2026-07-28 revision (draft; subject to
// change until it ships) removes `initialize` entirely (replaced by server/discover
// + per-request _meta, SEP-2575), so a probe that sent method:"initialize" with a
// 2026-07-28 version string would be internally inconsistent. When ToolHive adopts
// 2026-07-28 (issue #5754) the probe must switch to server/discover — which, like
// all 2026-07-28 Streamable HTTP POSTs, carries the required Mcp-Method/Mcp-Name
// headers — and fall back to this initialize path for older backends. Pinning here
// keeps the probe's method and version consistent regardless of what the SDK later
// declares "latest".
//
// The value is sent only in the request body, NOT as an MCP-Protocol-Version
// header: the spec scopes that header to requests made AFTER initialization
// (carrying the negotiated version), and requires a server to reject an unsupported
// header value with HTTP 400. Sending it on the initialize request itself would let
// a backend that only supports an older revision 400 the probe — a false
// "not-ready" — whereas body-based version negotiation degrades gracefully (the
// server answers HTTP 200 with its own supported version).
const probeProtocolVersion = "2025-11-25"

// Runner is responsible for running an MCP server with the provided configuration
type Runner struct {
// Config is the configuration for the runner
Expand Down Expand Up @@ -997,9 +1021,12 @@ func waitForInitializeSuccess(
// Format: http://localhost:port/mcp
endpoint = serverURL
method = "POST"
payload = `{"jsonrpc":"2.0","method":"initialize","id":"toolhive-init-check",` +
`"params":{"protocolVersion":"2024-11-05","capabilities":{},` +
`"clientInfo":{"name":"toolhive","version":"1.0"}}}`
payload = fmt.Sprintf(
`{"jsonrpc":"2.0","method":"initialize","id":"toolhive-init-check",`+
`"params":{"protocolVersion":%q,"capabilities":{},`+
`"clientInfo":{"name":"toolhive","version":"1.0"}}}`,
probeProtocolVersion,
)
case "sse":
// For SSE, just check if the SSE endpoint is available
// We can't easily call initialize without establishing a full SSE connection,
Expand Down Expand Up @@ -1049,7 +1076,10 @@ func waitForInitializeSuccess(
if method == "POST" {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("MCP-Protocol-Version", "2024-11-05")
// No MCP-Protocol-Version header: it is scoped to post-initialize
// requests (carrying the negotiated version) and a server MUST reject
// an unsupported value with HTTP 400. The initialize body's
// protocolVersion negotiates gracefully instead. See probeProtocolVersion.
}

resp, err := httpClient.Do(req) // #nosec G704 -- endpoint is the local MCP server readiness URL
Expand Down
49 changes: 49 additions & 0 deletions pkg/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package runner

import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -267,6 +269,53 @@ func TestWaitForInitializeSuccess(t *testing.T) {
assert.NoError(t, err)
})

t.Run("Streamable sends pinned probe protocol version in body only", func(t *testing.T) {
t.Parallel()

var (
mu sync.Mutex
capturedBody string
capturedHeader []string
headerPresent bool
)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)

mu.Lock()
capturedBody = string(body)
capturedHeader, headerPresent = r.Header["Mcp-Protocol-Version"]
mu.Unlock()

w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
}))
t.Cleanup(server.Close)

ctx := context.Background()
err := waitForInitializeSuccess(ctx, server.URL, "streamable-http", false, 5*time.Second)
require.NoError(t, err)

mu.Lock()
defer mu.Unlock()
// Assert against an independent literal (not probeProtocolVersion) so that
// changing the pin forces a conscious test update — the const is deliberately
// pinned and must not silently track mcp.LATEST_PROTOCOL_VERSION.
assert.Contains(t, capturedBody, `"protocolVersion":"2025-11-25"`)
assert.NotContains(t, capturedBody, "2024-11-05")
// The probe intentionally does not advertise the 2026-07-28 revision, which
// removes the initialize method the probe relies on.
assert.NotContains(t, capturedBody, "2026-07-28")
// The MCP-Protocol-Version header is intentionally NOT sent on the initialize
// request: it is scoped to post-initialize requests and a server MUST 400 an
// unsupported value, which would falsely mark an older backend as not-ready.
assert.False(t, headerPresent, "initialize probe must not send an MCP-Protocol-Version header, got %q", capturedHeader)
})

t.Run("SSE success", func(t *testing.T) {
t.Parallel()

Expand Down
Loading