mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 23:53:19 -10:00
* fix(mcp): harden streamable HTTP compatibility Problem: Stateless Streamable HTTP servers could block startup on a buffered subscriptions/listen request. Transport-level 404 and structured 4xx responses could also poison or misclassify the shared session, while failed protocol negotiation leaked an allocated server session. Root cause: The optional SEP-2575 subscription call remained on the synchronous connect path, and go-sdk v1.7.0 lacked request rejection, stateless subscription 404, and failed-negotiation cleanup fixes. Reasonix also relied only on the SDK session-missing sentinel. Fix: Run Streamable HTTP subscriptions asynchronously with session-scoped cancellation, pin the upstream SDK commit containing the three transport fixes, and narrowly classify explicit JSON-RPC session-loss errors plus sessionless endpoint 404s. Add compatibility coverage for buffered and live subscription streams, stateless 404s, per-call 4xx responses, negotiation cleanup, and legacy sessionless POST-only servers. Verification: - go test ./internal/plugin -count=1 - focused HTTP compatibility tests repeated 30 times - focused go test -race repeated 5 times - go test ./... -count=1 - go mod tidy -diff - git diff --check * fix(mcp): require transport proof before replay Problem: Application-level JSON-RPC errors containing session-loss wording could be mistaken for an expired MCP transport session, allowing a side-effecting tools/call to be replayed. The desktop nested module also retained the previous MCP SDK version and failed CI tidy checks. Root cause: Session recovery trusted server error text without requiring HTTP transport-rejection evidence, and the root SDK bump was not propagated through desktop's replace-based module graph. Fix: Require an SDK transport-rejection marker plus HTTP 404 evidence for structured session-loss recovery, scope sessionless 404 handling to Streamable HTTP, add no-replay regression coverage, and synchronize desktop go.mod/go.sum. Verification: - go test ./... -count=1 - cd desktop && go test ./... -count=1 - focused MCP race tests repeated 5 times - go vet ./... - go run ./tools/repolint - root and desktop go mod tidy -diff - ./scripts/cache-guard.sh - git diff --check * chore(mcp): document exact error-tree traversal Problem: CI errorlint rejected the deliberate direct inspection used to distinguish multiple wrapped JSON-RPC errors. Root cause: errors.As returns only the first matching RPC error, while session recovery must observe both the server error and the separate SDK transport-rejection sentinel. Fix: Document the invariant and scope the errorlint exception to the exact tree-walking helper. Verification: - golangci-lint run --timeout=5m ./... - go run ./tools/repolint - go test ./internal/plugin -count=1 - git diff --check * chore(mcp): format lint directive Problem: The scoped errorlint directive was not gofmt-clean because its explanatory comment required a separating blank comment line. Root cause: The CI formatter enforces gofmt over the complete root module before running tests. Fix: Apply gofmt to the directive comment block. Verification: - root-module gofmt check - golangci-lint run --timeout=5m ./... - go run ./tools/repolint - git diff --check
193 lines
5.4 KiB
Go
193 lines
5.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
|
|
mcpjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func isExplicitMCPSessionMissing(err error) bool {
|
|
if errors.Is(err, mcpsdk.ErrSessionMissing) {
|
|
return true
|
|
}
|
|
if !isMCPHTTPNotFound(err) || !hasMCPTransportRejection(err) {
|
|
return false
|
|
}
|
|
found := false
|
|
visitMCPRPCErrors(err, func(rpcErr *mcpjsonrpc.Error) {
|
|
message := strings.ToLower(strings.TrimSpace(rpcErr.Message))
|
|
for _, marker := range []string{
|
|
"session not found",
|
|
"session missing",
|
|
"session expired",
|
|
"invalid session",
|
|
"unknown session",
|
|
} {
|
|
if strings.Contains(message, marker) {
|
|
found = true
|
|
}
|
|
}
|
|
})
|
|
return found
|
|
}
|
|
|
|
// isMCPHTTPNotFound recognizes the status-only error emitted by newer Go MCP
|
|
// SDKs for a plain HTTP 404 when no session ID exists. It intentionally does
|
|
// not match arbitrary "not found" prose so a tool-level domain error cannot be
|
|
// mistaken for an endpoint or protocol mismatch.
|
|
func isMCPHTTPNotFound(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
hasRPCError := false
|
|
visitMCPRPCErrors(err, func(*mcpjsonrpc.Error) {
|
|
hasRPCError = true
|
|
})
|
|
if hasRPCError && !hasMCPTransportRejection(err) {
|
|
return false
|
|
}
|
|
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
return message == "not found" ||
|
|
strings.HasSuffix(message, ": not found") ||
|
|
strings.Contains(message, "http 404") ||
|
|
strings.Contains(message, "status 404")
|
|
}
|
|
|
|
func hasMCPTransportRejection(err error) bool {
|
|
found := false
|
|
visitMCPRPCErrors(err, func(rpcErr *mcpjsonrpc.Error) {
|
|
if rpcErr.Code == -32005 && strings.EqualFold(strings.TrimSpace(rpcErr.Message), "rejected by transport") {
|
|
found = true
|
|
}
|
|
})
|
|
return found
|
|
}
|
|
|
|
// visitMCPRPCErrors walks every concrete error-tree node because errors.As
|
|
// returns only the first matching RPC error and would hide transport evidence.
|
|
//
|
|
//nolint:errorlint // Direct inspection distinguishes server errors from the SDK transport sentinel.
|
|
func visitMCPRPCErrors(err error, visit func(*mcpjsonrpc.Error)) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
if rpcErr, ok := err.(*mcpjsonrpc.Error); ok && rpcErr != nil {
|
|
visit(rpcErr)
|
|
}
|
|
switch wrapped := err.(type) {
|
|
case interface{ Unwrap() []error }:
|
|
for _, child := range wrapped.Unwrap() {
|
|
visitMCPRPCErrors(child, visit)
|
|
}
|
|
case interface{ Unwrap() error }:
|
|
visitMCPRPCErrors(wrapped.Unwrap(), visit)
|
|
}
|
|
}
|
|
|
|
func (t *sdkSessionTransport) isStreamableHTTPNotFound(err error) bool {
|
|
return canonicalMCPRuntimeTransport(t.spec.Type) == "streamable-http" && isMCPHTTPNotFound(err)
|
|
}
|
|
|
|
func isTerminalSDKError(err error) bool {
|
|
return errors.Is(err, mcpsdk.ErrConnectionClosed) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
|
|
}
|
|
|
|
func isAmbiguousTransportError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
for _, marker := range []string{"connection reset", "broken pipe", "connection aborted", "connection refused", "transport is closing"} {
|
|
if strings.Contains(message, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func classifySessionError(err error) SessionErrorKind {
|
|
if err == nil {
|
|
return SessionErrorNone
|
|
}
|
|
switch {
|
|
case isExplicitMCPSessionMissing(err):
|
|
return SessionErrorSessionMissing
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
return SessionErrorTimeout
|
|
case isTerminalSDKError(err):
|
|
return SessionErrorStreamClosed
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
switch {
|
|
case strings.Contains(lower, "unauthorized"), strings.Contains(lower, "forbidden"), strings.Contains(lower, "authorize again"), strings.Contains(lower, "authentication"):
|
|
return SessionErrorAuthRequired
|
|
case strings.Contains(lower, "protocol version"), strings.Contains(lower, "method not found"):
|
|
return SessionErrorProtocol
|
|
default:
|
|
return SessionErrorTransport
|
|
}
|
|
}
|
|
|
|
type sanitizedMCPError struct {
|
|
message string
|
|
cause error
|
|
}
|
|
|
|
func (e *sanitizedMCPError) Error() string { return e.message }
|
|
func (e *sanitizedMCPError) Unwrap() error { return e.cause }
|
|
|
|
func (t *sdkSessionTransport) sanitizeError(err error, managed *managedMCPSession) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
sessionID := ""
|
|
if managed != nil && managed.session != nil {
|
|
sessionID = managed.session.ID()
|
|
}
|
|
return &sanitizedMCPError{message: t.safeErrorText(err, sessionID), cause: err}
|
|
}
|
|
|
|
func (t *sdkSessionTransport) safeErrorText(err error, sessionID string) string {
|
|
return redactMCPConfigValues(safeMCPErrorText(err, sessionID), t.spec)
|
|
}
|
|
|
|
func redactMCPConfigValues(message string, spec Spec) string {
|
|
values := make([]string, 0, len(spec.Headers)+len(spec.Env)+2)
|
|
values = append(values, spec.WorkspaceRoot, spec.Dir)
|
|
for _, value := range spec.Headers {
|
|
values = append(values, value)
|
|
}
|
|
for _, value := range spec.Env {
|
|
values = append(values, value)
|
|
}
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
message = strings.ReplaceAll(message, value, "[redacted]")
|
|
}
|
|
}
|
|
return message
|
|
}
|
|
|
|
func safeMCPErrorText(err error, sessionID string) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
message := summarizeFailureError(err)
|
|
if sessionID != "" {
|
|
message = strings.ReplaceAll(message, sessionID, "[redacted]")
|
|
}
|
|
if index := strings.Index(strings.ToLower(message), "session id:"); index >= 0 {
|
|
start := index + len("session id:")
|
|
end := strings.IndexByte(message[start:], ')')
|
|
if end >= 0 {
|
|
message = message[:start] + " [redacted]" + message[start+end:]
|
|
}
|
|
}
|
|
return message
|
|
}
|