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
11 changes: 10 additions & 1 deletion internal/stackql/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ func runMCPServer(handlerCtx handler.HandlerContext) {
if config.Server.Transport == "" {
config.Server.Transport = mcpServerType
}
// MCP clients must be able to distinguish "query ran, zero rows" from
// "query failed upstream" (issue #670), so data acquisition failures
// surface as statement errors rather than empty result sets. This is
// deliberately scoped to the MCP server; CLI and pgsrv sessions keep
// the historical empty-result-plus-stderr-notice semantics.
handlerCtx.SetStrictUpstreamErrors(true)
bi := buildinfo.Get()
transport := config.Server.Transport
runtimeContext := handlerCtx.GetRuntimeContext()
Expand Down Expand Up @@ -159,5 +165,8 @@ func runMCPServer(handlerCtx handler.HandlerContext) {
logging.GetLogger(),
)
iqlerror.PrintErrorAndExitOneIfError(serverErr)
server.Start(context.Background()) //nolint:errcheck // TODO: investigate
// A transport/session failure must be loud: log it and exit non-zero
// rather than silently returning success (issue #668).
startErr := server.Start(context.Background())
iqlerror.PrintErrorAndExitOneIfError(startErr)
}
72 changes: 72 additions & 0 deletions internal/stackql/execution/mono_valent_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"

"github.com/stackql/any-sdk/pkg/client"
Expand Down Expand Up @@ -39,6 +41,56 @@ var (
MonitorPollIntervalSeconds int = 10 //nolint:revive,gochecknoglobals // TODO: global vars refactor
)

// The naked HTTP status code is not carried through the any-sdk invoker
// boundary (providerinvoker.Result is just {Body, Messages}), so not-found
// detection has to work from upstream error text. That text is NOT uniform:
// any-sdk may publish its own "Status code NNN" prose, a parsed JSON error
// envelope, or a verbatim HTML / XML / plain-text body. Interim measure
// pending naked status code propagation from any-sdk (companion issues:
// any-sdk to expose the status code on the invoker result, stackql to
// consume it and delete this text matching).
//
// upstreamStatusCodeRegex matches any-sdk's structured fragments, eg
// "HTTP response error. Status code 404." and
// "Response error. Status code 404. Body: ...". A sibling of the
// mcpbackend classifier regex; duplicated to keep this package free of
// dependencies on the MCP surface.
var upstreamStatusCodeRegex = regexp.MustCompile(`(?i)status code:?\s*(\d{3})`) //nolint:gochecknoglobals // compiled once

// upstreamNotFoundLooseRegex matches not-found semantics however an
// unstructured upstream body phrases them: a standalone 404, or the phrase
// "not found", anywhere, any case (eg Go's "404 page not found", nginx's
// "<title>404 Not Found</title>", XML fault strings). A false positive
// here just restores the historical empty-result behaviour, which is the
// safe direction.
var upstreamNotFoundLooseRegex = regexp.MustCompile(`(?i)\b404\b|not\s+found`) //nolint:gochecknoglobals // compiled once

// isUpstreamNotFound reports whether every upstream error message indicates
// HTTP 404. Querying an absent resource legitimately yields 404 from
// providers and maps onto an empty result set under stackql's database
// semantics, so it is exempt from strict upstream error handling
// (issue #670). When a message carries any-sdk's structured "Status code
// NNN" fragment that number is authoritative (a 403 whose body mentions
// "not found" is still an error); only unstructured text falls back to the
// loose match.
func isUpstreamNotFound(messages []string) bool {
if len(messages) == 0 {
return false
}
for _, message := range messages {
if match := upstreamStatusCodeRegex.FindStringSubmatch(message); match != nil {
if match[1] != "404" {
return false
}
continue
}
if !upstreamNotFoundLooseRegex.MatchString(message) {
return false
}
}
return true
}

//nolint:gochecknoglobals // TODO: global vars refactor
var (
_ MonoValentExecutorFactory = (*monoValentExecution)(nil)
Expand Down Expand Up @@ -1546,6 +1598,26 @@ func (mv *monoValentExecution) GetExecutor() (func(pc primitive.IPrimitiveCtx) i
if len(invRes.Messages) > 0 {
castMessages = internaldto.NewBackendMessages(invRes.Messages)
}
// On the data acquisition (SELECT-shaped) path the any-sdk
// invoker only attaches messages when the upstream response was
// erroneous (eg HTTP 4xx / 5xx with a parseable error body, in
// which case the parsed error body may also be returned as a
// singleton Body); success messages are exclusive to mutation
// and skip-response flows. Under strict upstream error
// semantics (the MCP server, issue #670) that must surface as a
// statement error so callers can distinguish "zero rows" from
// "query failed". HTTP 404 is exempt: stackql presents provider
// resources as tables, so "not found" is the database-semantics
// equivalent of zero rows and keeps the historical empty-result
// behaviour everywhere, MCP included.
if mv.handlerCtx.IsStrictUpstreamErrors() &&
!mv.isMutation && !mv.isSkipResponse && len(invRes.Messages) > 0 &&
!isUpstreamNotFound(invRes.Messages) {
return internaldto.NewExecutorOutput(
nil, nil, nil, castMessages,
fmt.Errorf("upstream provider error: %s", strings.Join(invRes.Messages, "; ")),
)
}
if invRes.Body == nil {
return internaldto.NewExecutorOutput(nil, nil, nil, castMessages, nil)
}
Expand Down
82 changes: 82 additions & 0 deletions internal/stackql/execution/upstream_not_found_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package execution //nolint:testpackage // exercise unexported helper

import "testing"

func TestIsUpstreamNotFound(t *testing.T) {
cases := []struct {
name string
messages []string
want bool
}{
{
name: "single 404 detail form",
messages: []string{"HTTP response error. Status code 404. Detail: 'not found'"},
want: true,
},
{
name: "single 404 body form",
messages: []string{`Response error. Status code 404. Body: {"message":"Not Found"}`},
want: true,
},
{
name: "403 is not absence",
messages: []string{"Response error. Status code 403. Body: {}"},
want: false,
},
{
name: "mixed 404 and 500 is not absence",
messages: []string{
"Response error. Status code 404. Body: {}",
"Response error. Status code 500. Body: {}",
},
want: false,
},
{
name: "no status code fragment and no not-found phrasing",
messages: []string{"something else entirely"},
want: false,
},
{
name: "empty messages",
messages: nil,
want: false,
},
{
name: "unstructured go default body",
messages: []string{"404 page not found"},
want: true,
},
{
name: "unstructured html body",
messages: []string{"<html><head><title>404 Not Found</title></head><body><h1>NOT FOUND</h1></body></html>"},
want: true,
},
{
name: "unstructured xml fault",
messages: []string{"<?xml version=\"1.0\"?><Error><Code>404</Code><Message>The specified key does not exist.</Message></Error>"},
want: true,
},
{
name: "unstructured mixed case phrase only",
messages: []string{"the requested thing was Not FOUND on this server"},
want: true,
},
{
name: "structured 403 with not-found wording in body is still an error",
messages: []string{`Response error. Status code 403. Body: {"message":"secret not found for caller"}`},
want: false,
},
{
name: "digits embedded in larger number are not a 404",
messages: []string{"upstream failure, trace id 14042"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isUpstreamNotFound(tc.messages); got != tc.want {
t.Errorf("isUpstreamNotFound(%v) = %t, want %t", tc.messages, got, tc.want)
}
})
}
}
81 changes: 50 additions & 31 deletions internal/stackql/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ type HandlerContext interface { //nolint:revive // don't mind stuttering this on
//
SetConfigAtPath(path string, rhs interface{}, scope string) error

// Upstream (provider HTTP) error strictness. When true, data
// acquisition failures (eg HTTP 4xx / 5xx on a SELECT) surface as
// statement errors rather than empty result sets. Off by default to
// preserve historical CLI / pgsrv semantics; the MCP server turns it
// on (issue #670).
IsStrictUpstreamErrors() bool
SetStrictUpstreamErrors(bool)

// for testing only
SetDefaultHTTPClient(client *http.Client)
}
Expand Down Expand Up @@ -145,13 +153,23 @@ type standardHandlerContext struct {
exportNamespace string
stackqlSemver string
defaultHTTPClient *http.Client
// strictUpstreamErrors is documented on the HandlerContext interface.
strictUpstreamErrors bool
}

// for testing only.
func (hc *standardHandlerContext) SetDefaultHTTPClient(client *http.Client) {
hc.defaultHTTPClient = client
}

func (hc *standardHandlerContext) IsStrictUpstreamErrors() bool {
return hc.strictUpstreamErrors
}

func (hc *standardHandlerContext) SetStrictUpstreamErrors(isStrict bool) {
hc.strictUpstreamErrors = isStrict
}

func (hc *standardHandlerContext) GetDataFlowCfg() dto.DataFlowCfg {
return dto.NewDataFlowCfg(
hc.runtimeContext.DataflowDependencyMax,
Expand Down Expand Up @@ -528,37 +546,38 @@ func (hc *standardHandlerContext) Clone() HandlerContext {
hc.sessionCtxMutex.Lock()
defer hc.sessionCtxMutex.Unlock()
rv := standardHandlerContext{
authMapMutex: hc.authMapMutex,
sessionCtxMutex: hc.sessionCtxMutex,
providersMapMutex: hc.providersMapMutex,
drmConfig: hc.drmConfig,
rawQuery: hc.rawQuery,
runtimeContext: hc.runtimeContext,
currentProvider: hc.currentProvider,
providers: hc.providers,
authContexts: hc.authContexts,
registry: hc.registry,
controlAttributes: hc.controlAttributes,
errorPresentation: hc.errorPresentation,
lRUCache: hc.lRUCache,
sqlEngine: hc.sqlEngine,
sqlDataSources: hc.sqlDataSources,
sqlSystem: hc.sqlSystem,
persistenceSystem: hc.persistenceSystem,
garbageCollector: hc.garbageCollector,
outErrFile: hc.outErrFile,
outfile: hc.outfile,
txnCounterMgr: hc.txnCounterMgr,
txnStore: hc.txnStore,
namespaceCollection: hc.namespaceCollection,
formatter: hc.formatter,
pgInternalRouter: hc.pgInternalRouter,
txnCoordinatorCtx: hc.txnCoordinatorCtx,
typCfg: hc.typCfg,
sessionContext: hc.sessionContext,
exportNamespace: hc.exportNamespace,
stackqlSemver: hc.stackqlSemver,
defaultHTTPClient: hc.defaultHTTPClient,
authMapMutex: hc.authMapMutex,
sessionCtxMutex: hc.sessionCtxMutex,
providersMapMutex: hc.providersMapMutex,
drmConfig: hc.drmConfig,
rawQuery: hc.rawQuery,
runtimeContext: hc.runtimeContext,
currentProvider: hc.currentProvider,
providers: hc.providers,
authContexts: hc.authContexts,
registry: hc.registry,
controlAttributes: hc.controlAttributes,
errorPresentation: hc.errorPresentation,
lRUCache: hc.lRUCache,
sqlEngine: hc.sqlEngine,
sqlDataSources: hc.sqlDataSources,
sqlSystem: hc.sqlSystem,
persistenceSystem: hc.persistenceSystem,
garbageCollector: hc.garbageCollector,
outErrFile: hc.outErrFile,
outfile: hc.outfile,
txnCounterMgr: hc.txnCounterMgr,
txnStore: hc.txnStore,
namespaceCollection: hc.namespaceCollection,
formatter: hc.formatter,
pgInternalRouter: hc.pgInternalRouter,
txnCoordinatorCtx: hc.txnCoordinatorCtx,
typCfg: hc.typCfg,
sessionContext: hc.sessionContext,
exportNamespace: hc.exportNamespace,
stackqlSemver: hc.stackqlSemver,
defaultHTTPClient: hc.defaultHTTPClient,
strictUpstreamErrors: hc.strictUpstreamErrors,
}
return &rv
}
Expand Down
65 changes: 65 additions & 0 deletions internal/stackql/mcpbackend/error_classification_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package mcpbackend //nolint:testpackage // exercise unexported classifier

import (
"fmt"
"strings"
"testing"
)

func TestClassifyBackendError_UpstreamStatusVariants(t *testing.T) {
cases := []struct {
name string
err error
status int
retryable bool
}{
{
name: "any-sdk detail form 403",
err: fmt.Errorf(
"upstream provider error: HTTP response error. Status code 403. Detail: 'rate limit exceeded'"),
status: 403,
retryable: false,
},
{
name: "any-sdk body form 429",
err: fmt.Errorf(`upstream provider error: Response error. Status code 429. Body: {"message":"slow down"}`),
status: 429,
retryable: true,
},
{
name: "client log form 503",
err: fmt.Errorf("http response status code: 503, response body is nil"),
status: 503,
retryable: true,
},
{
name: "request timeout 408",
err: fmt.Errorf("upstream provider error: HTTP response error. Status code 408. Detail: 'timeout'"),
status: 408,
retryable: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifyBackendError(tc.err)
wantFragment := fmt.Sprintf(`{"http_status": %d, "retryable": %t}`, tc.status, tc.retryable)
if !strings.Contains(got.Error(), wantFragment) {
t.Errorf("expected %q in classified error, got %q", wantFragment, got.Error())
}
if !strings.Contains(got.Error(), tc.err.Error()) {
t.Errorf("expected underlying detail preserved, got %q", got.Error())
}
})
}
}

func TestClassifyBackendError_NonHTTPKeepsLegacyPrefix(t *testing.T) {
err := fmt.Errorf("'registry list' is meaningless in local mode")
got := classifyBackendError(err)
if !strings.Contains(got.Error(), "failed to extract query results") {
t.Errorf("expected legacy prefix, got %q", got.Error())
}
if !strings.Contains(got.Error(), "meaningless in local mode") {
t.Errorf("expected underlying detail preserved, got %q", got.Error())
}
}
Loading
Loading