Merge d8b77086c2 into 412705783d
This commit is contained in:
commit
3cbfaf85a7
5 changed files with 115 additions and 14 deletions
|
|
@ -139,7 +139,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
return err
|
logger.WarnCF("agent", "MCP initialization failed, continuing without MCP tools",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
idleTicker := time.NewTicker(100 * time.Millisecond)
|
idleTicker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,8 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
return "", err
|
logger.WarnCF("agent", "MCP initialization failed, processing without MCP tools",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
|
|
@ -73,7 +74,8 @@ func (al *AgentLoop) ProcessHeartbeat(
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
return "", err
|
logger.WarnCF("agent", "MCP initialization failed, processing heartbeat without MCP tools",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
|
|
||||||
|
|
@ -390,8 +390,8 @@ func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID s
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
logger.WarnCF("agent", "MCP initialization failed, continuing steering without MCP tools",
|
||||||
return "", err
|
map[string]any{"error": err.Error(), "session_key": sessionKey})
|
||||||
}
|
}
|
||||||
|
|
||||||
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
|
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
|
@ -381,14 +383,25 @@ func connectServer(
|
||||||
DisableStandaloneSSE: disableStandaloneSSE,
|
DisableStandaloneSSE: disableStandaloneSSE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up HTTP client with transport-level timeouts to avoid hanging
|
||||||
|
// indefinitely during connection establishment, while allowing SSE
|
||||||
|
// streams to remain open without a global request timeout.
|
||||||
|
baseTransport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpHTTPClient := &http.Client{
|
||||||
|
Transport: baseTransport,
|
||||||
|
}
|
||||||
|
|
||||||
// Add custom headers if provided
|
// Add custom headers if provided
|
||||||
if len(cfg.Headers) > 0 {
|
if len(cfg.Headers) > 0 {
|
||||||
// Create a custom HTTP client with header-injecting transport
|
// Create a custom HTTP client with header-injecting transport
|
||||||
sseTransport.HTTPClient = &http.Client{
|
mcpHTTPClient.Transport = &headerTransport{
|
||||||
Transport: &headerTransport{
|
base: baseTransport,
|
||||||
base: http.DefaultTransport,
|
headers: cfg.Headers,
|
||||||
headers: cfg.Headers,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
logger.DebugCF("mcp", "Added custom HTTP headers",
|
logger.DebugCF("mcp", "Added custom HTTP headers",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -397,6 +410,8 @@ func connectServer(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sseTransport.HTTPClient = mcpHTTPClient
|
||||||
|
|
||||||
transport = sseTransport
|
transport = sseTransport
|
||||||
case "stdio":
|
case "stdio":
|
||||||
if cfg.Command == "" {
|
if cfg.Command == "" {
|
||||||
|
|
@ -457,8 +472,11 @@ func connectServer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to server
|
// Connect to server with a timeout-scoped context to avoid hanging
|
||||||
session, err := client.Connect(ctx, transport, nil)
|
// indefinitely when the MCP server is unreachable.
|
||||||
|
connectCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
session, err := client.Connect(connectCtx, transport, nil)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -473,8 +491,10 @@ func connectServer(
|
||||||
"protocol": initResult.ProtocolVersion,
|
"protocol": initResult.ProtocolVersion,
|
||||||
})
|
})
|
||||||
|
|
||||||
// List available tools if supported
|
// List available tools if supported, with a timeout-scoped context
|
||||||
tools, err := listServerTools(ctx, name, session, initResult)
|
listCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
tools, err := listServerTools(listCtx, name, session, initResult)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = session.Close()
|
_ = session.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -628,3 +630,79 @@ func (t *scriptedTransport) Close() error {
|
||||||
func (t *scriptedTransport) SessionID() string {
|
func (t *scriptedTransport) SessionID() string {
|
||||||
return t.sessionID
|
return t.sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHTTPClientTransportTimeouts verifies that the HTTP client is configured
|
||||||
|
// with transport-level timeouts instead of client-wide timeout.
|
||||||
|
// This addresses the PR review comment:
|
||||||
|
// "http.Client.Timeout applies to the full request lifetime, including reading
|
||||||
|
// the response body. On StreamableClientTransport in sse mode that will terminate
|
||||||
|
// the long-lived SSE stream after 30s"
|
||||||
|
func TestHTTPClientTransportTimeouts(t *testing.T) {
|
||||||
|
// Verify that transport-level timeouts are configured correctly
|
||||||
|
// instead of using http.Client.Timeout which would affect SSE streams
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify transport timeouts are set correctly
|
||||||
|
// This ensures SSE streams won't be terminated by client-wide timeout
|
||||||
|
if transport.TLSHandshakeTimeout != 10*time.Second {
|
||||||
|
t.Errorf("TLSHandshakeTimeout should be 10s, got %v", transport.TLSHandshakeTimeout)
|
||||||
|
}
|
||||||
|
if transport.ResponseHeaderTimeout != 30*time.Second {
|
||||||
|
t.Errorf("ResponseHeaderTimeout should be 30s, got %v", transport.ResponseHeaderTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DialContext is set (not DialTimeout which is deprecated)
|
||||||
|
if transport.DialContext == nil {
|
||||||
|
t.Error("DialContext should be set for connection timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContextTimeoutForConnect verifies that connect operations use timeout-scoped context.
|
||||||
|
func TestContextTimeoutForConnect(t *testing.T) {
|
||||||
|
// Test that a context with timeout properly cancels the operation
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create a listener that delays acceptance
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create listener: %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
// Start a goroutine that delays accepting connections
|
||||||
|
go func() {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
conn, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
mgr := NewManager()
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err = mgr.ConnectServer(ctx, "timeout-test", config.MCPServerConfig{
|
||||||
|
Type: "sse",
|
||||||
|
URL: "http://" + listener.Addr().String() + "/mcp",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected connection to fail due to context timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail quickly due to context timeout, not hang
|
||||||
|
if duration > 500*time.Millisecond {
|
||||||
|
t.Fatalf("Operation took too long: %v, expected context timeout around 100ms", duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue