fix(mcp): stop reporting a dropped backend response as success (#3562)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package mcp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -13,6 +14,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ErrEmptyBackendPayload identifies a response with no payload bytes when a result sink was provided.
|
||||
var ErrEmptyBackendPayload = errors.New("mcp backend response payload was empty")
|
||||
|
||||
// MCPRequest represents an MCP request to be sent to the backend.
|
||||
type MCPRequest struct {
|
||||
ID string `json:"id"`
|
||||
@@ -236,10 +240,19 @@ func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string
|
||||
}
|
||||
return fmt.Errorf("backend error: %s", string(resp.Payload))
|
||||
}
|
||||
if result != nil && len(resp.Payload) > 0 {
|
||||
if err := json.Unmarshal(resp.Payload, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if len(resp.Payload) == 0 {
|
||||
c.logger.Warn(ErrEmptyBackendPayload.Error(),
|
||||
zap.String("request_id", id),
|
||||
zap.String("action", action),
|
||||
zap.String("session_id", response.sessionID),
|
||||
zap.Duration("duration", time.Since(start)))
|
||||
return fmt.Errorf("empty response payload for action %q: %w", action, ErrEmptyBackendPayload)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Payload, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
@@ -126,6 +128,154 @@ func TestChannelBackendClientTerminalFailureLogIncludesConfiguredSession(t *test
|
||||
require.Equal(t, "session-log", entries[0].ContextMap()["session_id"])
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.1
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.2
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.9
|
||||
func TestChannelBackendClientEmptyPayloadWithResultSinkErrors(t *testing.T) {
|
||||
core, observed := observer.New(zap.WarnLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
client := NewChannelBackendClient(log)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
var result map[string]interface{}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", map[string]any{"secret": "canary-leak-9f3a"}, &result)
|
||||
}()
|
||||
request := <-client.GetRequestChannel()
|
||||
|
||||
response, err := ws.NewResponse(request.ID, request.Action, nil)
|
||||
require.NoError(t, err)
|
||||
response.Payload = nil
|
||||
client.HandleResponse(response)
|
||||
|
||||
respErr := <-errCh
|
||||
require.Error(t, respErr)
|
||||
require.ErrorIs(t, respErr, ErrEmptyBackendPayload)
|
||||
require.Contains(t, respErr.Error(), "test.action")
|
||||
require.NotContains(t, respErr.Error(), "secret")
|
||||
|
||||
entries := observed.FilterMessage(ErrEmptyBackendPayload.Error()).All()
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, zapcore.WarnLevel, entries[0].Level)
|
||||
fields := entries[0].ContextMap()
|
||||
require.Equal(t, request.ID, fields["request_id"])
|
||||
require.Equal(t, "test.action", fields["action"])
|
||||
require.Contains(t, fields, "session_id")
|
||||
require.Contains(t, fields, "duration")
|
||||
|
||||
logJSON, err := json.Marshal(fields)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(logJSON), "secret")
|
||||
require.NotContains(t, string(logJSON), "canary-leak-9f3a")
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.3
|
||||
func TestChannelBackendClientEmptyPayloadWithNilResultSucceeds(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", nil, nil)
|
||||
}()
|
||||
request := <-client.GetRequestChannel()
|
||||
|
||||
response, err := ws.NewResponse(request.ID, request.Action, nil)
|
||||
require.NoError(t, err)
|
||||
response.Payload = nil
|
||||
client.HandleResponse(response)
|
||||
|
||||
require.NoError(t, <-errCh)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.4
|
||||
func TestChannelBackendClientErrorTypeWithEmptyPayloadKeepsBackendErrorBehavior(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
var result map[string]interface{}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
}()
|
||||
request := <-client.GetRequestChannel()
|
||||
|
||||
response := &ws.Message{ID: request.ID, Action: request.Action, Type: ws.MessageTypeError, Payload: nil}
|
||||
client.HandleResponse(response)
|
||||
|
||||
respErr := <-errCh
|
||||
require.Error(t, respErr)
|
||||
require.NotErrorIs(t, respErr, ErrEmptyBackendPayload)
|
||||
require.Contains(t, respErr.Error(), "backend error")
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.5
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.8
|
||||
func TestChannelBackendClientEmptyObjectPayloadDecodesToNonNilEmptyMap(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
var result map[string]interface{}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
}()
|
||||
request := <-client.GetRequestChannel()
|
||||
|
||||
response, err := ws.NewResponse(request.ID, request.Action, map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
client.HandleResponse(response)
|
||||
|
||||
require.NoError(t, <-errCh)
|
||||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.6
|
||||
func TestChannelBackendClientNullPayloadStillDecodesWithoutError(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
result := map[string]interface{}{"stale": "value"}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
}()
|
||||
request := <-client.GetRequestChannel()
|
||||
|
||||
response, err := ws.NewResponse(request.ID, request.Action, nil)
|
||||
require.NoError(t, err)
|
||||
client.HandleResponse(response)
|
||||
|
||||
require.NoError(t, <-errCh)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.7
|
||||
func TestChannelBackendClientInvalidJSONPayloadKeepsUnmarshalError(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
var result map[string]interface{}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
}()
|
||||
request := <-client.GetRequestChannel()
|
||||
|
||||
response, err := ws.NewResponse(request.ID, request.Action, nil)
|
||||
require.NoError(t, err)
|
||||
response.Payload = []byte("not json")
|
||||
client.HandleResponse(response)
|
||||
|
||||
respErr := <-errCh
|
||||
require.Error(t, respErr)
|
||||
require.Contains(t, respErr.Error(), "failed to unmarshal")
|
||||
require.NotErrorIs(t, respErr, ErrEmptyBackendPayload)
|
||||
}
|
||||
|
||||
func TestChannelBackendClientRedactsPluginInvocationPayload(t *testing.T) {
|
||||
core, observed := observer.New(zap.DebugLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
|
||||
@@ -75,10 +75,17 @@ func (c *DispatcherBackendClient) RequestPayload(ctx context.Context, action str
|
||||
}
|
||||
return fmt.Errorf("backend error: %s", string(resp.Payload))
|
||||
}
|
||||
if result != nil && len(resp.Payload) > 0 {
|
||||
if err := json.Unmarshal(resp.Payload, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response for %s: %w", action, err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if len(resp.Payload) == 0 {
|
||||
c.logger.Warn(ErrEmptyBackendPayload.Error(),
|
||||
zap.String("request_id", id),
|
||||
zap.String("action", action))
|
||||
return fmt.Errorf("empty response payload for action %q: %w", action, ErrEmptyBackendPayload)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Payload, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response for %s: %w", action, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
mcporigin "github.com/kandev/kandev/internal/mcp/origin"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
// fakeDispatcher is a minimal Dispatcher used to drive DispatcherBackendClient tests.
|
||||
@@ -114,3 +118,115 @@ func TestDispatcherBackendClient_NilResultIsAllowed(t *testing.T) {
|
||||
err = client.RequestPayload(context.Background(), "test.action", nil, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.1
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.2
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.9
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.10
|
||||
func TestDispatcherBackendClient_EmptyPayloadWithResultSinkErrors(t *testing.T) {
|
||||
core, observed := observer.New(zap.WarnLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &fakeDispatcher{resp: &ws.Message{Type: ws.MessageTypeResponse, Payload: nil}}
|
||||
client := NewDispatcherBackendClient(d, log)
|
||||
|
||||
var result map[string]interface{}
|
||||
respErr := client.RequestPayload(context.Background(), "test.action", map[string]any{"secret": "canary-leak-9f3a"}, &result)
|
||||
require.Error(t, respErr)
|
||||
require.ErrorIs(t, respErr, ErrEmptyBackendPayload)
|
||||
require.Contains(t, respErr.Error(), "test.action")
|
||||
require.NotContains(t, respErr.Error(), "secret")
|
||||
|
||||
require.Len(t, d.calls, 1)
|
||||
outboundID := d.calls[0].ID
|
||||
require.NotEmpty(t, outboundID)
|
||||
|
||||
entries := observed.FilterMessage(ErrEmptyBackendPayload.Error()).All()
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, zapcore.WarnLevel, entries[0].Level)
|
||||
fields := entries[0].ContextMap()
|
||||
require.Equal(t, outboundID, fields["request_id"])
|
||||
require.Equal(t, "test.action", fields["action"])
|
||||
_, hasSessionID := fields["session_id"]
|
||||
require.False(t, hasSessionID)
|
||||
_, hasDuration := fields["duration"]
|
||||
require.False(t, hasDuration)
|
||||
|
||||
logJSON, err := json.Marshal(fields)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(logJSON), "secret")
|
||||
require.NotContains(t, string(logJSON), "canary-leak-9f3a")
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.3
|
||||
func TestDispatcherBackendClient_EmptyPayloadWithNilResultSucceeds(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
d := &fakeDispatcher{resp: &ws.Message{Type: ws.MessageTypeResponse, Payload: nil}}
|
||||
client := NewDispatcherBackendClient(d, log)
|
||||
|
||||
err := client.RequestPayload(context.Background(), "test.action", nil, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.4
|
||||
func TestDispatcherBackendClient_ErrorTypeWithEmptyPayloadKeepsBackendErrorBehavior(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
d := &fakeDispatcher{resp: &ws.Message{Type: ws.MessageTypeError, Payload: nil}}
|
||||
client := NewDispatcherBackendClient(d, log)
|
||||
|
||||
var result map[string]interface{}
|
||||
err := client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrEmptyBackendPayload)
|
||||
require.Contains(t, err.Error(), "backend error")
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.5
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.8
|
||||
func TestDispatcherBackendClient_EmptyObjectPayloadDecodesToNonNilEmptyMap(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
respMsg, err := ws.NewResponse("ignored", "test.action", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
d := &fakeDispatcher{resp: respMsg}
|
||||
client := NewDispatcherBackendClient(d, log)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.6
|
||||
func TestDispatcherBackendClient_NullPayloadStillDecodesWithoutError(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
respMsg, err := ws.NewResponse("ignored", "test.action", nil)
|
||||
require.NoError(t, err)
|
||||
d := &fakeDispatcher{resp: respMsg}
|
||||
client := NewDispatcherBackendClient(d, log)
|
||||
|
||||
result := map[string]interface{}{"stale": "value"}
|
||||
err = client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.7
|
||||
func TestDispatcherBackendClient_InvalidJSONPayloadKeepsUnmarshalError(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
respMsg := &ws.Message{Type: ws.MessageTypeResponse, Payload: json.RawMessage("not json")}
|
||||
d := &fakeDispatcher{resp: respMsg}
|
||||
client := NewDispatcherBackendClient(d, log)
|
||||
|
||||
var result map[string]interface{}
|
||||
err := client.RequestPayload(context.Background(), "test.action", nil, &result)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "failed to unmarshal")
|
||||
require.NotErrorIs(t, err, ErrEmptyBackendPayload)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @covers AC-AGENTS-MCP-BRIDGE-RELIABILITY-002.5
|
||||
// TestGetTaskPlan_EmptyObjectPayloadStillRendersNoPlan guards the transport
|
||||
// change against the deliberately unchanged "no plan" convention: a `{}`
|
||||
// backend response must keep decoding successfully so this rendering is
|
||||
// unaffected by the new empty-payload rule.
|
||||
func TestGetTaskPlan_EmptyObjectPayloadStillRendersNoPlan(t *testing.T) {
|
||||
backend := &testBackend{response: map[string]interface{}{}}
|
||||
s := newTaskModeServer(t, backend, "task-A")
|
||||
|
||||
result := callTool(t, s, "get_task_plan_kandev", map[string]interface{}{})
|
||||
|
||||
require.False(t, result.IsError)
|
||||
require.Equal(t, "No plan exists for this task yet.", resultText(t, result))
|
||||
}
|
||||
Reference in New Issue
Block a user