Skip to content

fix(canary): initialize MCP session before tools/call - #107

Merged
mashkovd merged 3 commits into
mainfrom
fix/canary-mcp-session-init
May 21, 2026
Merged

mashkovd merged 3 commits into
mainfrom
fix/canary-mcp-session-init

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Summary

  • The canary was going directly to tools/call without the MCP initialize handshake, causing HTTP 404 on every probe step
  • Adds initMCPSession that POSTs {"method":"initialize"}, reads the Mcp-Session-Id response header, and passes it to each subsequent probeMCPTool call via the Mcp-Session-Id request header
  • Adds mcp_init as an explicit probe step — session failures now appear in mctl_telegram_canary_step_failure_total{step=mcp_init} rather than as unexplained list_dialogs failures
  • Three new unit tests for initMCPSession; integration test fake server updated to handle initialize automatically

Test plan

  • go test ./cmd/canary/... passes
  • Re-run canary smoke test job in cluster — all steps succeed
  • mctl_telegram_canary_success gauge = 1 in Pushgateway

🤖 Generated with Claude Code

The MCP Streamable HTTP transport requires a session initialization
handshake (method: initialize) before any tools/call request. Without
it the server returns HTTP 404 on every probe step.

Adds initMCPSession which POSTs the initialize request, reads the
Mcp-Session-Id response header, and returns it to the caller. The
session ID is then forwarded via Mcp-Session-Id on every subsequent
probeMCPTool call.

Also adds a mcp_init probe step so session-initialization failures are
surfaced in mctl_telegram_canary_step_failure_total{step=mcp_init}
rather than appearing as an unexplained list_dialogs failure.

Tests: three new unit tests for initMCPSession; integration test
newFakeServer updated to handle initialize automatically.
@mashkovd

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 37s —— View job


Review complete

  • Gather context
  • Read changed files
  • Analyze diff
  • Post findings

Summary

The fix is correct. The root cause (missing MCP initialize handshake) is properly addressed, the new mcp_init step integrates cleanly with the existing probe pattern, metrics instrumentation is consistent, and the integration test helper handles the routing transparently without leaking into individual test cases.

P1: 0  |  P2: 0  |  P3: 3

# File Finding
P3 cmd/canary/main.go:268 initMCPSession never drains the response body — prevents TCP connection reuse (inconsistent with probeMCPTool)
P3 cmd/canary/main_test.go:303 string(body) in newFakeServer body re-injection; bytes.NewReader(body) avoids the copy
P3 cmd/canary/main_test.go:134 TestInitMCPSessionSuccess handler doesn't assert request shape (method field, auth header) — regressions in request format would go undetected

No P1/P2 findings, good to merge.

Comment thread cmd/canary/main.go
return "", errors.New("server did not return Mcp-Session-Id header")
}

return sessionID, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The response body is never drained before defer resp.Body.Close() fires (including on both early-return error paths). net/http only reuses keep-alive connections when the body is fully consumed; skipping the drain forces a new TCP connection for every subsequent request in the same run. For a canary making ~3 requests this has no functional impact, but it's inconsistent with probeMCPTool (which calls io.ReadAll).

Suggested change
return sessionID, nil
_, _ = io.Copy(io.Discard, resp.Body)
return sessionID, nil
}

Add _, _ = io.Copy(io.Discard, resp.Body) before the final return sessionID, nil, and a similar drain before each early return — or restructure to always drain once after the status check.

Comment thread cmd/canary/main_test.go Outdated
return
}
// Re-inject the already-read body for the delegate handler.
r.Body = io.NopCloser(strings.NewReader(string(body)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — string(body) copies the byte slice unnecessarily; bytes.NewReader(body) avoids the allocation and is more idiomatic here.

Suggested change
r.Body = io.NopCloser(strings.NewReader(string(body)))
r.Body = io.NopCloser(bytes.NewReader(body))

Comment thread cmd/canary/main_test.go
"result": map[string]any{"protocolVersion": "2024-11-05"},
})
}))
defer srv.Close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The handler accepts any POST without verifying the request shape (method field, Authorization header, Content-Type). If the request body format regresses the test still passes as long as the server returns the header. A quick assertion on the method field and auth header would make the test catch more regressions at no real cost.

if r.Header.Get("Authorization") != "Bearer token" {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    return
}
var body jsonRPCRequest
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Method != "initialize" {
    http.Error(w, "unexpected method", http.StatusBadRequest)
    return
}

@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 29s —— View job


Review complete

  • Gather context
  • Read changed files and diff
  • Analyze changes since last review
  • Post findings

Summary

The fix remains correct — the MCP initialize handshake is properly sequenced, mcp_init integrates cleanly with the existing probe pattern, metrics labelling is consistent, and the integration test helper routes transparently without leaking into individual test cases.

The three P3 findings from the previous review pass are still present and unaddressed.

P1: 0  |  P2: 0  |  P3: 3

# File Finding
P3 cmd/canary/main.go:268 initMCPSession never drains the response body — prevents TCP connection reuse (inconsistent with probeMCPTool)
P3 cmd/canary/main_test.go:303 strings.NewReader(string(body)) in newFakeServer copies the byte slice unnecessarily; bytes.NewReader(body) avoids the allocation
P3 cmd/canary/main_test.go:133 TestInitMCPSessionSuccess handler doesn't assert request shape (method field, auth header) — regressions in request format would go undetected

No P1/P2 findings, good to merge.

- drain response body in initMCPSession on all return paths to enable
  TCP connection reuse (consistent with probeMCPTool)
- use bytes.NewReader instead of strings.NewReader in newFakeServer body
  re-injection (avoids string copy)
- assert Authorization header and method field in TestInitMCPSessionSuccess
  so request format regressions are caught
@claude

claude Bot commented May 21, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 41s —— View job


Review complete

  • Gather context
  • Read changed files and diff
  • Analyze changes since last review
  • Post findings

Summary

The follow-up commit ("fix(canary): address P3 review findings") resolves all three findings from the prior review:

# Finding Status
P3 initMCPSession never drained response body Fixed — _, _ = io.Copy(io.Discard, resp.Body) on all three return paths
P3 string(body) copy in newFakeServer re-injection Fixed — io.NopCloser(bytes.NewReader(body))
P3 TestInitMCPSessionSuccess didn't assert request shape Fixed — handler now checks Authorization header and method field

P1: 0  |  P2: 0  |  P3: 0

No new issues introduced. No P1/P2 findings, good to merge.

Comment thread cmd/canary/main.go
if sessionID == "" {
_, _ = io.Copy(io.Discard, resp.Body)
return "", errors.New("server did not return Mcp-Session-Id header")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Response body is not drained before return sessionID, nil, and also not drained on the early-return error path at line 260. net/http only reuses keep-alive connections when the body is fully consumed; skipping the drain forces a new TCP connection on every subsequent call. Inconsistent with probeMCPTool (which calls io.ReadAll).

Suggested change
}
_, _ = io.Copy(io.Discard, resp.Body)
return sessionID, nil
}

Also add a similar drain (or restructure) before each early return "", … after defer resp.Body.Close().

Comment thread cmd/canary/main_test.go
Method string `json:"method"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16))
_ = json.Unmarshal(body, &req)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — string(body) copies the byte slice into a new string just to wrap it in a strings.NewReader; bytes.NewReader avoids the allocation and is idiomatic here.

Suggested change
_ = json.Unmarshal(body, &req)
r.Body = io.NopCloser(bytes.NewReader(body))

Comment thread cmd/canary/main_test.go
return
}
var req jsonRPCRequest
_ = json.NewDecoder(r.Body).Decode(&req)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The handler accepts any POST without verifying the request shape (method field, auth header, Content-Type). If the request body format or auth header regresses, the test still passes as long as the server returns the session-id header. A short assertion block would catch more regressions at no real cost:

if r.Header.Get("Authorization") != "Bearer token" {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    return
}
var body jsonRPCRequest
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Method != "initialize" {
    http.Error(w, "unexpected method", http.StatusBadRequest)
    return
}

@mashkovd
mashkovd merged commit 11b2795 into main May 21, 2026
6 checks passed
@mashkovd
mashkovd deleted the fix/canary-mcp-session-init branch May 21, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants