From a31b01664d3a191cfff2934b738dd24c18aa79dc Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Fri, 3 Jul 2026 15:49:49 +1000 Subject: [PATCH 1/3] fix: mcp server CRLF stdio tolerance (#668), json render option (#669), upstream http error propagation (#670) + robot coverage Issue #668: the go-sdk ndjson decoder rejects the bare carriage return in CRLF-terminated JSON-RPC frames and the session error was silently discarded, so the stdio server exited 0 with no output. The stdio transport now strips carriage returns from stdin (lossless: raw CR is illegal inside JSON strings) and the mcp command exits non-zero with the error printed when the server run fails. Issue #669: tool result text content can now be rendered as compact JSON of the structured DTO (sql.Null wrappers unwrapped) instead of markdown, via a per-call 'format' argument (markdown | json) on query / metadata / registry tool inputs, or a server-level {"server": {"render": "json"}} default in --mcp.config. The test mcp client gained a {"prefer_text": true} client-cfg to surface text content. Issue #670: SELECTs that fail upstream (HTTP 4xx / 5xx) no longer return an empty result set over MCP. A new strict-upstream-errors flag on the handler context - set only by the mcp command, so CLI and pgsrv keep the historical empty-result-plus-stderr-notice semantics - converts acquisition failures into statement errors, and the MCP backend classifies them as upstream http error {"http_status": N, "retryable": bool} (408 / 429 / 5xx retryable). Robot coverage: stdio LF + CRLF roundtrips via a new python stdio harness, markdown default / per-call json / server-level json / invalid format scenarios, and 403 (non-retryable) + 429 (retryable) upstream error scenarios against new github mock routes. Co-Authored-By: Claude Fable 5 --- internal/stackql/cmd/mcp.go | 11 +- .../execution/mono_valent_execution.go | 17 ++ internal/stackql/handler/handler.go | 81 +++++---- .../mcpbackend/error_classification_test.go | 65 +++++++ .../stackql/mcpbackend/mcp_service_stackql.go | 63 +++++-- pkg/mcp_server/client.go | 24 ++- pkg/mcp_server/client_format_test.go | 30 +++- pkg/mcp_server/config.go | 20 +++ pkg/mcp_server/dto/dto.go | 3 + pkg/mcp_server/render/render.go | 45 +++++ pkg/mcp_server/render/render_test.go | 32 ++++ pkg/mcp_server/server.go | 158 +++++++++++++++-- .../stackql_test_tooling/flask/github/app.py | 30 ++++ .../stackql_test_tooling/mcp_stdio_client.py | 115 ++++++++++++ test/robot/functional/mcp.robot | 165 ++++++++++++++++++ 15 files changed, 794 insertions(+), 65 deletions(-) create mode 100644 internal/stackql/mcpbackend/error_classification_test.go create mode 100644 test/python/stackql_test_tooling/mcp_stdio_client.py diff --git a/internal/stackql/cmd/mcp.go b/internal/stackql/cmd/mcp.go index e26e40a8..775e2356 100644 --- a/internal/stackql/cmd/mcp.go +++ b/internal/stackql/cmd/mcp.go @@ -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() @@ -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) } diff --git a/internal/stackql/execution/mono_valent_execution.go b/internal/stackql/execution/mono_valent_execution.go index 41044b91..cb197123 100644 --- a/internal/stackql/execution/mono_valent_execution.go +++ b/internal/stackql/execution/mono_valent_execution.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/stackql/any-sdk/pkg/client" @@ -1546,6 +1547,22 @@ 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". + if mv.handlerCtx.IsStrictUpstreamErrors() && + !mv.isMutation && !mv.isSkipResponse && len(invRes.Messages) > 0 { + 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) } diff --git a/internal/stackql/handler/handler.go b/internal/stackql/handler/handler.go index 1f62300e..affe16d0 100644 --- a/internal/stackql/handler/handler.go +++ b/internal/stackql/handler/handler.go @@ -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) } @@ -145,6 +153,8 @@ type standardHandlerContext struct { exportNamespace string stackqlSemver string defaultHTTPClient *http.Client + // strictUpstreamErrors is documented on the HandlerContext interface. + strictUpstreamErrors bool } // for testing only. @@ -152,6 +162,14 @@ 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, @@ -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 } diff --git a/internal/stackql/mcpbackend/error_classification_test.go b/internal/stackql/mcpbackend/error_classification_test.go new file mode 100644 index 00000000..46d66fb6 --- /dev/null +++ b/internal/stackql/mcpbackend/error_classification_test.go @@ -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()) + } +} diff --git a/internal/stackql/mcpbackend/mcp_service_stackql.go b/internal/stackql/mcpbackend/mcp_service_stackql.go index 352ec0d8..e5c031b7 100644 --- a/internal/stackql/mcpbackend/mcp_service_stackql.go +++ b/internal/stackql/mcpbackend/mcp_service_stackql.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "io" + "net/http" + "regexp" + "strconv" "strings" "github.com/sirupsen/logrus" @@ -304,13 +307,49 @@ func (b *stackqlMCPService) RunQueryJSON(ctx context.Context, input dto.QueryJSO } func (b *stackqlMCPService) runPreprocessedQueryJSON(_ context.Context, query string, rowLimit int) ([]map[string]interface{}, error) { - results, ok := b.extractQueryResults(query, rowLimit) - if !ok { - return nil, fmt.Errorf("failed to extract query results") + results, extractErr := b.extractQueryResults(query, rowLimit) + if extractErr != nil { + return nil, extractErr } return results, nil } +// upstreamStatusCodeRegex matches the status code fragments any-sdk embeds in +// upstream response error strings, eg "HTTP response error. Status code 403.", +// "Response error. Status code 429." and "http response status code: 503". +var upstreamStatusCodeRegex = regexp.MustCompile(`(?i)status code:?\s*(\d{3})`) //nolint:gochecknoglobals // compiled once + +// isRetryableHTTPStatus classifies upstream statuses for MCP clients: +// 408 / 429 / 5xx are worth retrying, other 4xx are not (issue #670). +func isRetryableHTTPStatus(status int) bool { + return status == http.StatusRequestTimeout || + status == http.StatusTooManyRequests || + status >= http.StatusInternalServerError +} + +// classifyBackendError decorates a statement error for the MCP surface. +// When the error carries an upstream HTTP status, the returned error text is +// prefixed with a machine-readable classification ({"http_status": ..., +// "retryable": ...}); anything else keeps the historical +// "failed to extract query results" prefix with the underlying detail +// appended (issue #670). +func classifyBackendError(err error) error { + match := upstreamStatusCodeRegex.FindStringSubmatch(err.Error()) + if match == nil { + return fmt.Errorf("failed to extract query results: %w", err) + } + status, convErr := strconv.Atoi(match[1]) + if convErr != nil { + return fmt.Errorf("failed to extract query results: %w", err) + } + return fmt.Errorf( + `upstream http error: {"http_status": %d, "retryable": %t}: %w`, + status, + isRetryableHTTPStatus(status), + err, + ) +} + // ExecQuery returns {messages, timestamp}: messages is the orchestrator's // per-statement message list (eg "The operation was despatched successfully"), // timestamp is the wall-clock dispatch time. The reverse-proxy backend @@ -356,8 +395,11 @@ func (b *stackqlMCPService) applyQuery(query string) ([]internaldto.ExecutorOutp return r, ok } -func (b *stackqlMCPService) extractQueryResults(query string, rowLimit int) ([]map[string]interface{}, bool) { +func (b *stackqlMCPService) extractQueryResults(query string, rowLimit int) ([]map[string]interface{}, error) { r, ok := b.applyQuery(query) + if !ok { + return nil, fmt.Errorf("failed to extract query results") + } // Initialise as empty (not nil) so a zero-row result survives downstream // JSON-array schema validation on QueryResultDTO.Rows. This pairs with // fix 1 (returning ok regardless of len(rv)) so empty results render as @@ -366,8 +408,10 @@ func (b *stackqlMCPService) extractQueryResults(query string, rowLimit int) ([]m rowCount := 0 for _, resp := range r { if respErr := resp.GetError(); respErr != nil { - ok = false - break + // Propagate the statement error (with upstream HTTP status + // classification where available) rather than collapsing it + // into an indistinct empty result (issue #670). + return nil, classifyBackendError(respErr) } // PrepareResultSet emits a nil SQLResult when RowMap is empty (eg // REGISTRY LIST against an empty registry). That's a zero-row @@ -379,17 +423,16 @@ func (b *stackqlMCPService) extractQueryResults(query string, rowLimit int) ([]m var drainOK bool rv, rowCount, drainOK = drainSQLRowStream(sqlRowStream, rv, rowCount, rowLimit) if !drainOK { - ok = false - break + return nil, fmt.Errorf("failed to extract query results") } } - return rv, ok + return rv, nil } // drainSQLRowStream reads `stream` to EOF (or until rowLimit is reached), // appending each row's payload to `rv`. The returned bool is false when the // stream surfaces a read error or a nil row outside of EOF; that maps onto -// extractQueryResults' (rv, false) failure mode. +// extractQueryResults' error failure mode. func drainSQLRowStream( stream sqldata.ISQLResultStream, rv []map[string]interface{}, diff --git a/pkg/mcp_server/client.go b/pkg/mcp_server/client.go index e0f62563..b8f78353 100644 --- a/pkg/mcp_server/client.go +++ b/pkg/mcp_server/client.go @@ -350,24 +350,40 @@ func (c *httpMCPClient) CallToolText(toolName string, args map[string]any) (stri if toolCallErr != nil { return "", toolCallErr } - return formatToolResult(toolName, toolCall) + return formatToolResult(toolName, toolCall, c.prefersText()) +} + +// prefersText reports whether the client config requests the rendered text +// content blocks instead of the structured payload (`"prefer_text": true`). +// Useful for exercising / consuming the server's text renderings, eg the +// JSON render option of issue #669. +func (c *httpMCPClient) prefersText() bool { + if c.clientCfg == nil { + return false + } + preferText, isBool := c.clientCfg["prefer_text"].(bool) + return isBool && preferText } // formatToolResult shapes a CallToolResult for a scripting client. // Order of preference: -// 1. StructuredContent re-marshalled as compact JSON (the typed DTO). -// 2. The concatenated TextContent blocks, if no structured payload is present. +// 1. The concatenated TextContent blocks, when preferText is set. +// 2. StructuredContent re-marshalled as compact JSON (the typed DTO). +// 3. The concatenated TextContent blocks, if no structured payload is present. // // Tool-level errors (IsError == true) are returned as a Go error containing the // text payload, so a CLI caller exits non-zero and the message ends up on // stderr - matching the existing transport-error convention. -func formatToolResult(toolName string, toolCall *mcp.CallToolResult) (string, error) { +func formatToolResult(toolName string, toolCall *mcp.CallToolResult, preferText bool) (string, error) { if toolCall == nil { return "", fmt.Errorf("tool %s returned no result", toolName) } if toolCall.IsError { return "", fmt.Errorf("tool %s: %s", toolName, extractText(toolCall)) } + if preferText { + return extractText(toolCall), nil + } if toolCall.StructuredContent != nil { raw, err := json.Marshal(toolCall.StructuredContent) if err != nil { diff --git a/pkg/mcp_server/client_format_test.go b/pkg/mcp_server/client_format_test.go index 3f0424ef..2bd79f57 100644 --- a/pkg/mcp_server/client_format_test.go +++ b/pkg/mcp_server/client_format_test.go @@ -16,7 +16,7 @@ func TestFormatToolResult_PrefersStructuredContent(t *testing.T) { "is_read_only": false, }, } - out, err := formatToolResult("server_info", res) + out, err := formatToolResult("server_info", res, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -28,11 +28,29 @@ func TestFormatToolResult_PrefersStructuredContent(t *testing.T) { } } +func TestFormatToolResult_PreferTextReturnsTextContent(t *testing.T) { + // `"prefer_text": true` in the client config must surface the rendered + // text content even when a structured payload is present (issue #669). + res := &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"rows":[{"name":"google"}]}`}}, + StructuredContent: map[string]any{ + "rows": []any{map[string]any{"name": "google", "extra": "structured-only"}}, + }, + } + out, err := formatToolResult("list_providers", res, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != `{"rows":[{"name":"google"}]}` { + t.Errorf("expected text content verbatim, got %q", out) + } +} + func TestFormatToolResult_FallsBackToTextWhenNoStructured(t *testing.T) { res := &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: "plain output"}}, } - out, err := formatToolResult("anything", res) + out, err := formatToolResult("anything", res, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -46,7 +64,7 @@ func TestFormatToolResult_IsErrorReturnsErrorWithTextPayload(t *testing.T) { IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "tool 'run_mutation_query' refused: server is read-only"}}, } - _, err := formatToolResult("run_mutation_query", res) + _, err := formatToolResult("run_mutation_query", res, false) if err == nil { t.Fatal("expected error for IsError result") } @@ -56,7 +74,7 @@ func TestFormatToolResult_IsErrorReturnsErrorWithTextPayload(t *testing.T) { } func TestFormatToolResult_NilResult(t *testing.T) { - _, err := formatToolResult("anything", nil) + _, err := formatToolResult("anything", nil, false) if err == nil { t.Fatal("expected error for nil result") } @@ -80,7 +98,7 @@ func TestFormatToolResult_EmbeddedJSONStringInValueRoundtripsCleanly(t *testing. }, }, } - out, err := formatToolResult("describe_method", res) + out, err := formatToolResult("describe_method", res, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -114,7 +132,7 @@ func TestFormatToolResult_StructuredContentAsArray(t *testing.T) { }, }, } - out, err := formatToolResult("list_providers", res) + out, err := formatToolResult("list_providers", res, false) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/mcp_server/config.go b/pkg/mcp_server/config.go index c94fd1d0..682617d5 100644 --- a/pkg/mcp_server/config.go +++ b/pkg/mcp_server/config.go @@ -9,6 +9,7 @@ import ( "github.com/stackql/stackql/pkg/mcp_server/audit" "github.com/stackql/stackql/pkg/mcp_server/policy" + "github.com/stackql/stackql/pkg/mcp_server/render" "github.com/stackql/stackql/pkg/sink" ) @@ -92,6 +93,15 @@ func (c *Config) GetMode() string { return c.Server.Mode } +// GetRender returns the server-level default render format for tool result +// text content. Empty string is mapped to the markdown default. +func (c *Config) GetRender() string { + if c == nil || c.Server.Render == "" { + return render.FormatMarkdown + } + return c.Server.Render +} + // IsAuditEnabled reports whether audit logging should run. Audit is on by // default unless explicitly disabled. func (c *Config) IsAuditEnabled() bool { @@ -141,6 +151,11 @@ type ServerConfig struct { // and `read_only` are set, `mode` wins. Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` + // Render sets the server-level default for tool result text content. + // Legal values: "markdown" (default), "json". A per-call `format` + // argument on a tool overrides this default (issue #669). + Render string `json:"render,omitempty" yaml:"render,omitempty"` + // Audit configures the audit subsystem. Audit is enabled by default // (Disabled is false) and writes to a file sink. Audit AuditConfig `json:"audit,omitempty" yaml:"audit,omitempty"` @@ -161,6 +176,7 @@ type serverConfigWire struct { MaxConcurrentRequests int `json:"max_concurrent_requests" yaml:"max_concurrent_requests"` RequestTimeout Duration `json:"request_timeout" yaml:"request_timeout"` Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` + Render string `json:"render,omitempty" yaml:"render,omitempty"` Audit AuditConfig `json:"audit,omitempty" yaml:"audit,omitempty"` // LegacyReadOnly preserves the PR1 `read_only: true` wire form. LegacyReadOnly *bool `json:"read_only,omitempty" yaml:"read_only,omitempty"` @@ -179,6 +195,7 @@ func (s *ServerConfig) fromWire(w serverConfigWire) { s.MaxConcurrentRequests = w.MaxConcurrentRequests s.RequestTimeout = w.RequestTimeout s.Mode = w.Mode + s.Render = w.Render s.Audit = w.Audit // Legacy: `read_only: true` with no `mode` -> Mode = "read_only". // `mode` always wins. @@ -346,6 +363,9 @@ func (c *Config) Validate() error { if !policy.IsLegalMode(c.Server.Mode) { return fmt.Errorf("invalid server.mode %q (legal: read_only, safe, delete_safe, full_access)", c.Server.Mode) } + if !render.IsLegalFormat(c.Server.Render) { + return fmt.Errorf("invalid server.render %q (legal: markdown, json)", c.Server.Render) + } switch c.Server.Audit.GetFailureMode() { case audit.FailureModeStrict, audit.FailureModeStrictMutations, audit.FailureModeBestEffort: default: diff --git a/pkg/mcp_server/dto/dto.go b/pkg/mcp_server/dto/dto.go index 890d7295..44c183e3 100644 --- a/pkg/mcp_server/dto/dto.go +++ b/pkg/mcp_server/dto/dto.go @@ -7,6 +7,7 @@ type HierarchyInput struct { Resource string `json:"resource,omitempty" yaml:"resource,omitempty"` Method string `json:"method,omitempty" yaml:"method,omitempty"` RowLimit int `json:"row_limit,omitempty" yaml:"row_limit,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty" jsonschema:"text content render format: markdown (default) or json"` //nolint:lll // schema doc } // ServerInfoOutput is the backend-facing server info payload. @@ -41,6 +42,7 @@ type ServerInfoDTO struct { type QueryJSONInput struct { SQL string `json:"sql" yaml:"sql"` RowLimit int `json:"row_limit,omitempty" yaml:"row_limit,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty" jsonschema:"text content render format: markdown (default) or json"` //nolint:lll // schema doc } // RegistryInput is the shared input shape for list_registry and pull_provider. @@ -50,6 +52,7 @@ type QueryJSONInput struct { type RegistryInput struct { Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` Version string `json:"version,omitempty" yaml:"version,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty" jsonschema:"text content render format: markdown (default) or json"` //nolint:lll // schema doc } // QueryResultDTO is the typed structured payload returned alongside the rendered text. diff --git a/pkg/mcp_server/render/render.go b/pkg/mcp_server/render/render.go index ec6713e1..e549f382 100644 --- a/pkg/mcp_server/render/render.go +++ b/pkg/mcp_server/render/render.go @@ -2,6 +2,7 @@ package render import ( + "encoding/json" "fmt" "reflect" "sort" @@ -10,6 +11,50 @@ import ( const noResults = "**no results**" +// Text content render formats (issue #669). Markdown is the historical +// default; JSON renders the same DTO carried in structuredContent as compact +// JSON so text-only MCP consumers get a machine-readable payload. +const ( + FormatMarkdown = "markdown" + FormatJSON = "json" +) + +// IsLegalFormat reports whether the supplied format name is supported. +// The empty string is legal and means "use the default". +func IsLegalFormat(format string) bool { + switch format { + case "", FormatMarkdown, FormatJSON: + return true + default: + return false + } +} + +// JSONValue renders any value as compact JSON for text content. Row sets +// pass through UnwrapRows first so database/sql nullable wrappers serialise +// as their scalar payloads rather than {Valid, ...} envelopes. +func JSONValue(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf(`{"error":%q}`, fmt.Sprintf("failed to marshal: %v", err)) + } + return string(b) +} + +// UnwrapRows returns a copy of `rows` with database/sql nullable wrappers +// collapsed to their scalar payloads (see unwrap). +func UnwrapRows(rows []map[string]any) []map[string]any { + clean := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + cleanRow := make(map[string]any, len(row)) + for k, v := range row { + cleanRow[k] = unwrap(v) + } + clean = append(clean, cleanRow) + } + return clean +} + // unwrap normalises database/sql nullable wrappers (sql.NullString, NullBool, // NullInt64, NullInt32, NullFloat64, NullByte, NullTime, the generic sql.Null[T]) // down to their scalar payload before formatting. Anything else is returned diff --git a/pkg/mcp_server/render/render_test.go b/pkg/mcp_server/render/render_test.go index d79ce8b0..cfe8702a 100644 --- a/pkg/mcp_server/render/render_test.go +++ b/pkg/mcp_server/render/render_test.go @@ -98,3 +98,35 @@ func TestRender_InvalidNullableRendersAsEmpty(t *testing.T) { t.Errorf("expected empty cell for invalid Nullable, got %q", got) } } + +func TestIsLegalFormat(t *testing.T) { + for _, legal := range []string{"", render.FormatMarkdown, render.FormatJSON} { + if !render.IsLegalFormat(legal) { + t.Errorf("expected %q to be legal", legal) + } + } + if render.IsLegalFormat("yaml") { + t.Error("expected \"yaml\" to be illegal") + } +} + +func TestJSONValue_CompactJSON(t *testing.T) { + got := render.JSONValue(map[string]any{"rows": []map[string]any{{"name": "google"}}}) + if got != `{"rows":[{"name":"google"}]}` { + t.Errorf("expected compact JSON, got %q", got) + } +} + +func TestUnwrapRows_CollapsesNullableWrappers(t *testing.T) { + rows := []map[string]any{{ + "n": sql.NullInt64{Int64: 1, Valid: true}, + "status": sql.NullString{String: "ok", Valid: true}, + "invalid": sql.NullString{String: "ignored", Valid: false}, + }} + got := render.JSONValue(render.UnwrapRows(rows)) + for _, want := range []string{`"n":1`, `"status":"ok"`, `"invalid":""`} { + if !strings.Contains(got, want) { + t.Errorf("expected %q in unwrapped JSON, got %q", want, got) + } + } +} diff --git a/pkg/mcp_server/server.go b/pkg/mcp_server/server.go index 06b7442e..8aa7f6e6 100644 --- a/pkg/mcp_server/server.go +++ b/pkg/mcp_server/server.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "sync" "time" @@ -102,6 +103,34 @@ func mustMarshal(v any) string { return string(b) } +// resolveRenderFormat returns the effective text render format for a tool +// call: a legal per-call `format` argument wins, otherwise the server-level +// default applies (issue #669). An unrecognised per-call value is an error +// so machine callers fail fast rather than silently getting markdown. +func resolveRenderFormat(cfg *Config, requested string) (string, error) { + if !render.IsLegalFormat(requested) { + return "", fmt.Errorf("invalid format %q (legal: markdown, json)", requested) + } + if requested != "" { + return requested, nil + } + return cfg.GetRender(), nil +} + +// textForFormat renders the text content for a tool result. In JSON mode the +// value mirrored into structuredContent is rendered as compact JSON (row sets +// have their database/sql nullable wrappers unwrapped first); otherwise the +// supplied markdown renderer runs. +func textForFormat(format string, v any, markdown func() string) string { + if format == render.FormatJSON { + if q, isQueryResult := v.(dto.QueryResultDTO); isQueryResult { + v = dto.QueryResultDTO{Rows: render.UnwrapRows(q.Rows)} + } + return render.JSONValue(v) + } + return markdown() +} + // mcpDefaultAuditFilename returns the default audit log filename the MCP // server uses when the operator did not configure an explicit path. The // naming convention is the one shipped with PR2: stackql_mcp_server_.log @@ -253,7 +282,7 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log "mode": out.Mode, "is_read_only": out.ReadOnly, }} - text := render.RenderKV("Server Info", rec) + text := textForFormat(cfg.GetRender(), out, func() string { return render.RenderKV("Server Info", rec) }) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -270,7 +299,8 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderTable(rows)}}}, out, nil + text := textForFormat(cfg.GetRender(), out, func() string { return render.RenderTable(rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -281,12 +311,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Services under a provider. Requires provider.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.HierarchyInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.ListServices(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderTable(rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderTable(rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -297,12 +332,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Resources under a provider.service. Requires provider and service.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.HierarchyInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.ListResources(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderTable(rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderTable(rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -313,12 +353,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Access methods (HTTP operations) for a resource. Call before writing any query. Requires provider, service, resource.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.HierarchyInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.ListMethods(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderTable(rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderTable(rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -329,12 +374,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Output fields for a resource's primary read method. Requires provider, service, resource.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.HierarchyInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.DescribeResource(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderKV("Resource", rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderKV("Resource", rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -345,12 +395,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Full I/O contract for one method. Requires provider, service, resource, method.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.HierarchyInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.DescribeMethod(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderKV("Method", rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderKV("Method", rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -366,6 +421,10 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Parse and plan a SELECT without executing. Returns {valid, errors}. SELECT only.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.QueryJSONInput) (*mcp.CallToolResult, dto.ValidationResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.ValidationResultDTO{}, formatErr + } rowsBack, err := backend.ValidateQuery(ctx, args.SQL) isValid := err == nil var errs []string @@ -380,7 +439,7 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log if !isValid { rec[0]["explain_output"] = mustMarshal(rowsBack) } - text := render.RenderKV("Validation Result", rec) + text := textForFormat(format, out, func() string { return render.RenderKV("Validation Result", rec) }) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -393,12 +452,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.QueryJSONInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { logger.Debugf("run_select_query: %s", args.SQL) + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.RunQueryJSON(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderTable(rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderTable(rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -409,11 +473,15 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Execute INSERT/UPDATE/REPLACE/DELETE against the provider. Real side effects. Returns {messages, timestamp}. Gated by server mode.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.QueryJSONInput) (*mcp.CallToolResult, map[string]any, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, nil, formatErr + } res, err := backend.ExecQuery(ctx, args.SQL) if err != nil { return nil, nil, err } - text := render.RenderKV("Mutation Result", []map[string]any{res}) + text := textForFormat(format, res, func() string { return render.RenderKV("Mutation Result", []map[string]any{res}) }) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, res, nil }, ) @@ -425,11 +493,15 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Execute a stackql EXEC lifecycle operation. Returns {messages, timestamp}. Gated by server mode.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.QueryJSONInput) (*mcp.CallToolResult, map[string]any, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, nil, formatErr + } res, err := backend.ExecQuery(ctx, args.SQL) if err != nil { return nil, nil, err } - text := render.RenderKV("Lifecycle Result", []map[string]any{res}) + text := textForFormat(format, res, func() string { return render.RenderKV("Lifecycle Result", []map[string]any{res}) }) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, res, nil }, ) @@ -441,12 +513,17 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Providers (and their versions) available in the configured registry. Distinct from list_providers, which lists only providers already pulled. Optional provider arg lists versions for that provider.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.RegistryInput) (*mcp.CallToolResult, dto.QueryResultDTO, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, dto.QueryResultDTO{}, formatErr + } rows, err := backend.ListRegistry(ctx, args) if err != nil { return nil, dto.QueryResultDTO{}, err } out := dto.QueryResultDTO{Rows: rows} - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: render.RenderTable(rows)}}}, out, nil + text := textForFormat(format, out, func() string { return render.RenderTable(rows) }) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, out, nil }, ) @@ -457,11 +534,15 @@ func registerTools(server *mcp.Server, cfg *Config, backend Backend, logger *log Description: "Install a single provider from the registry into the local approot cache. Requires provider; version is optional (latest published is pulled when empty). Writes only local cache state; no cloud control/data plane effect.", }, func(ctx context.Context, _ *mcp.CallToolRequest, args dto.RegistryInput) (*mcp.CallToolResult, map[string]any, error) { + format, formatErr := resolveRenderFormat(cfg, args.Format) + if formatErr != nil { + return nil, nil, formatErr + } res, err := backend.PullProvider(ctx, args) if err != nil { return nil, nil, err } - text := render.RenderKV("Pull Result", []map[string]any{res}) + text := textForFormat(format, res, func() string { return render.RenderKV("Pull Result", []map[string]any{res}) }) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, res, nil }, ) @@ -510,12 +591,63 @@ func (s *simpleMCPServer) run(ctx context.Context) error { case serverTransportSSE: return fmt.Errorf("SSE transport obsoleted; use streamable HTTP transport instead") case serverTransportStdIO: + // Windows text-mode pipes CRLF-terminate JSON-RPC lines; the SDK's + // ndjson decoder treats the bare carriage return before the newline + // as trailing garbage and kills the session (issue #668). A raw CR + // is illegal inside a JSON string (control characters must be + // escaped), so stripping every CR from the inbound stream is + // lossless for spec-compliant traffic. + if filtered, filterErr := newCRStrippedStdin(); filterErr == nil { + os.Stdin = filtered + } else { + s.logger.Warnf("could not install CRLF-tolerant stdin filter: %v", filterErr) + } return s.server.Run(ctx, &mcp.StdioTransport{}) default: return fmt.Errorf("unsupported transport: %s", s.config.Server.Transport) } } +// crFilterReader removes carriage-return bytes from the wrapped stream. +type crFilterReader struct { + r io.Reader +} + +func (c *crFilterReader) Read(p []byte) (int, error) { + for { + n, err := c.r.Read(p) + kept := 0 + for i := 0; i < n; i++ { + if p[i] == '\r' { + continue + } + p[kept] = p[i] + kept++ + } + if kept > 0 || err != nil || len(p) == 0 { + return kept, err + } + // Every byte read was a CR; retry rather than returning (0, nil). + } +} + +// newCRStrippedStdin returns the read end of a pipe fed by a goroutine that +// copies the process's real stdin with all carriage returns removed. The +// pump goroutine lives for the remainder of the process, which matches the +// lifetime of a stdio MCP session. +func newCRStrippedStdin() (*os.File, error) { + pr, pw, err := os.Pipe() + if err != nil { + return nil, err + } + stdin := os.Stdin + go func() { + defer pw.Close() + io.Copy(pw, &crFilterReader{r: stdin}) //nolint:errcheck // EOF/close terminates the session anyway + }() + return pr, nil +} + // Stop gracefully stops the MCP server and all transports. func (s *simpleMCPServer) Stop() error { s.mu.Lock() diff --git a/test/python/stackql_test_tooling/flask/github/app.py b/test/python/stackql_test_tooling/flask/github/app.py index b901ef35..21541be9 100644 --- a/test/python/stackql_test_tooling/flask/github/app.py +++ b/test/python/stackql_test_tooling/flask/github/app.py @@ -420,6 +420,36 @@ def get_special_org_repos(): return jsonify(response), 200 +@app.route('/orgs/ratelimitedorg/repos', methods=['GET']) +def get_rate_limited_org_repos(): + """Mimics GitHub's rate-limit refusal: a 403 with a JSON object body. + + Exercised by the MCP robot scenarios for issue #670 (upstream HTTP errors + must surface as tool errors, classified as non-retryable for 403). + """ + logger.warning("Rate limited GET request for ratelimitedorg repos") + return jsonify({ + "message": "API rate limit exceeded for 111.1111.111.11.", + "documentation_url": "https://docs.github.com/rest/overview/rate-limits-for-the-rest-api", + "status": "403", + }), 403 + + +@app.route('/orgs/throttledorg/repos', methods=['GET']) +def get_throttled_org_repos(): + """A 429 with a JSON object body. + + Exercised by the MCP robot scenarios for issue #670 (429 must be + classified as retryable). + """ + logger.warning("Throttled GET request for throttledorg repos") + return jsonify({ + "message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again.", + "documentation_url": "https://docs.github.com/rest/overview/rate-limits-for-the-rest-api", + "status": "429", + }), 429 + + @app.route('/orgs//repos', methods=['GET']) def get_combined_org_repos(org_name): if org_name not in ["dummyorg", "stackql"]: diff --git a/test/python/stackql_test_tooling/mcp_stdio_client.py b/test/python/stackql_test_tooling/mcp_stdio_client.py new file mode 100644 index 00000000..52da82c2 --- /dev/null +++ b/test/python/stackql_test_tooling/mcp_stdio_client.py @@ -0,0 +1,115 @@ +"""Minimal stdio MCP harness used by robot scenarios. + +Drives a `stackql mcp --mcp.server.type=stdio` child process over raw byte +pipes with a configurable line terminator, so CRLF framing (issue #668) can +be exercised end to end. Deliberately binary-mode (no `text=True`) so the +requested terminator reaches the server byte-for-byte on every platform. +""" + +import json +import subprocess +import threading + +_LINE_ENDINGS = { + "lf": b"\n", + "crlf": b"\r\n", +} + + +def _frame_messages(messages, terminator): + return b"".join( + json.dumps(m, separators=(",", ":")).encode("utf-8") + terminator + for m in messages + ) + + +def run_stdio_initialize_roundtrip( + stackql_exe, + registry_cfg, + auth_cfg, + line_ending="lf", + timeout_seconds=90, +): + """Runs initialize -> initialized -> tools/list over stdio. + + Returns a dict with `stdout`, `stderr` and `returncode`. Responses for + request ids 1 (initialize) and 2 (tools/list) are awaited on stdout + before stdin is closed, so slow request handling cannot race the + EOF-triggered session shutdown. + """ + terminator = _LINE_ENDINGS[line_ending] + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "robot-stdio-harness", "version": "0.1.0"}, + }, + }, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + ] + argv = [ + stackql_exe, + "mcp", + "--mcp.server.type=stdio", + "--mcp.config", + '{"server": {"audit": {"disabled": true}} }', + "--registry", + registry_cfg, + "--auth", + auth_cfg, + "--tls.allowInsecure", + ] + proc = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + watchdog = threading.Timer(timeout_seconds, proc.kill) + watchdog.start() + stdout_lines = [] + stderr = b"" + try: + try: + proc.stdin.write(_frame_messages(messages, terminator)) + proc.stdin.flush() + except OSError: + # A server that dies on the first (CRLF-terminated) line breaks + # the pipe; fall through so assertions see the empty output. + pass + awaited_ids = {1, 2} + while awaited_ids: + line = proc.stdout.readline() + if not line: + # Server exited (or was killed by the watchdog) before + # responding; the caller's assertions surface the failure. + break + stdout_lines.append(line) + try: + decoded = json.loads(line) + except ValueError: + continue + if isinstance(decoded, dict): + awaited_ids.discard(decoded.get("id")) + try: + proc.stdin.close() + except OSError: + pass + # stdin is closed above, so the server sees EOF and exits; drain the + # remaining output directly (communicate() would re-flush the closed + # stdin and raise). + stdout_lines.append(proc.stdout.read()) + stderr = proc.stderr.read() + proc.wait(timeout=timeout_seconds) + finally: + watchdog.cancel() + return { + "stdout": b"".join(stdout_lines).decode("utf-8", errors="replace"), + "stderr": stderr.decode("utf-8", errors="replace"), + "returncode": proc.returncode, + } diff --git a/test/robot/functional/mcp.robot b/test/robot/functional/mcp.robot index 60b79ddd..510ed4c1 100644 --- a/test/robot/functional/mcp.robot +++ b/test/robot/functional/mcp.robot @@ -130,6 +130,20 @@ Start MCP Servers ... \-\-tls.allowInsecure ... stdout=${CURDIR}${/}tmp${/}Stackql-MCP-Server-Audit.txt ... stderr=${CURDIR}${/}tmp${/}Stackql-MCP-Server-Audit-stderr.txt + # Issue #669: server-level render default. Tool result text content is + # rendered as compact JSON rather than markdown. + Start Process ${STACKQL_EXE} + ... mcp + ... \-\-mcp.server.type\=http + ... \-\-mcp.config + ... {"server": {"transport": "http", "address": "127.0.0.1:9924", "mode": "full_access", "render": "json", "audit": {"disabled": true}} } + ... \-\-registry + ... ${REGISTRY_NO_VERIFY_CFG_JSON_STR} + ... \-\-auth + ... ${AUTH_CFG_STR} + ... \-\-tls.allowInsecure + ... stdout=${CURDIR}${/}tmp${/}Stackql-MCP-Server-Render-JSON.txt + ... stderr=${CURDIR}${/}tmp${/}Stackql-MCP-Server-Render-JSON-stderr.txt Sleep 5s Parse MCP JSON Output @@ -972,3 +986,154 @@ MCP HTTP Pull Provider Installs Known Provider ... stderr=${CURDIR}${/}tmp${/}MCP-Pull-Provider-stderr.txt Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} timestamp + +# =========================================================================== +# Issue #668 scenarios. The stdio transport must tolerate CRLF-terminated +# JSON-RPC frames (Windows text-mode pipes produce these); before the fix the +# server exited silently (code 0, no output) on the first CRLF line. The +# python harness drives the binary over raw byte pipes so the terminator +# reaches the server verbatim on every platform. +# =========================================================================== + +MCP Stdio Server Handles LF Terminated JSON RPC + [Documentation] Control scenario: LF framing worked before and after the fix. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + ${result}= Evaluate stackql_test_tooling.mcp_stdio_client.run_stdio_initialize_roundtrip($STACKQL_EXE, $REGISTRY_NO_VERIFY_CFG_JSON_STR, $AUTH_CFG_STR, line_ending='lf') modules=stackql_test_tooling.mcp_stdio_client + Should Contain ${result['stdout']} serverInfo + Should Contain ${result['stdout']} "tools" + Should Be Equal As Integers ${result['returncode']} 0 + +MCP Stdio Server Tolerates CRLF Terminated JSON RPC + [Documentation] Issue #668: CRLF-terminated JSON-RPC frames must be served, + ... not answered with a silent exit code 0. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + ${result}= Evaluate stackql_test_tooling.mcp_stdio_client.run_stdio_initialize_roundtrip($STACKQL_EXE, $REGISTRY_NO_VERIFY_CFG_JSON_STR, $AUTH_CFG_STR, line_ending='crlf') modules=stackql_test_tooling.mcp_stdio_client + Should Contain ${result['stdout']} serverInfo + Should Contain ${result['stdout']} "tools" + Should Be Equal As Integers ${result['returncode']} 0 + +# =========================================================================== +# Issue #669 scenarios. Tool result text content is markdown by default; a +# per-call `format` argument or a server-level `render` config switches it to +# compact JSON. The client's `prefer_text` config surfaces the text blocks +# (rather than structuredContent) so the rendering itself is asserted. +# =========================================================================== + +MCP HTTP Text Content Defaults To Markdown + [Documentation] Control scenario: without a format argument the 9912 server + ... (no render config) renders a markdown table. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9912 + ... \-\-client\-cfg {"prefer_text": true} + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT 1 as n, 'ok' as status;"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Text-Default-Markdown.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Text-Default-Markdown-stderr.txt + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} | n | status | + Should Contain ${result.stdout} | 1 | ok | + +MCP HTTP Per Call JSON Format Renders JSON Text + [Documentation] Issue #669: format=json on the call renders the DTO as + ... compact JSON in the text content, overriding the server's + ... markdown default. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9912 + ... \-\-client\-cfg {"prefer_text": true} + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT 1 as n, 'ok' as status;", "format": "json"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Text-PerCall-JSON.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Text-PerCall-JSON-stderr.txt + Should Be Equal As Integers ${result.rc} 0 + ${parsed}= Parse MCP JSON Output ${result.stdout} + Dictionary Should Contain Key ${parsed} rows + Should Be Equal As Strings ${parsed['rows'][0]['status']} ok + Should Not Contain ${result.stdout} | n | status | + +MCP HTTP Server Level JSON Render Default + [Documentation] Issue #669: the 9924 server is started with + ... {"server": {"render": "json"}}; text content is JSON with + ... no per-call argument. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9924 + ... \-\-client\-cfg {"prefer_text": true} + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT 1 as n, 'ok' as status;"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Text-ServerLevel-JSON.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Text-ServerLevel-JSON-stderr.txt + Should Be Equal As Integers ${result.rc} 0 + ${parsed}= Parse MCP JSON Output ${result.stdout} + Dictionary Should Contain Key ${parsed} rows + Should Be Equal As Strings ${parsed['rows'][0]['status']} ok + +MCP HTTP Invalid Format Argument Is Rejected + [Documentation] Issue #669: an unknown format value fails fast rather than + ... silently falling back to markdown. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9912 + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT 1 as n;", "format": "yaml"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Text-Invalid-Format.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Text-Invalid-Format-stderr.txt + Should Not Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stderr} invalid format + +# =========================================================================== +# Issue #670 scenarios. Upstream HTTP errors on SELECTs must surface as tool +# errors with an http_status / retryable classification, not as a successful +# empty result set. The github mock serves 403 for org `ratelimitedorg` and +# 429 for org `throttledorg`, both with JSON object bodies as GitHub sends. +# =========================================================================== + +MCP HTTP Upstream 403 Surfaces As Non Retryable Tool Error + [Documentation] Issue #670: a 403 from the provider becomes an MCP tool + ... error classified as non-retryable, not {"rows": []}. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9912 + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT id, name FROM github.repos.repos WHERE org \= 'ratelimitedorg';"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Upstream-403.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Upstream-403-stderr.txt + Should Not Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stderr} upstream http error + Should Contain ${result.stderr} "http_status": 403 + Should Contain ${result.stderr} "retryable": false + Should Not Contain ${result.stdout} "rows":[] + +MCP HTTP Upstream 429 Surfaces As Retryable Tool Error + [Documentation] Issue #670: a 429 from the provider becomes an MCP tool + ... error classified as retryable. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9912 + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT id, name FROM github.repos.repos WHERE org \= 'throttledorg';"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Upstream-429.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Upstream-429-stderr.txt + Should Not Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stderr} upstream http error + Should Contain ${result.stderr} "http_status": 429 + Should Contain ${result.stderr} "retryable": true From 7d8e8b444fcf8e4f5efb57deb3e98f3de529d83c Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Fri, 3 Jul 2026 18:07:32 +1000 Subject: [PATCH 2/3] fix: exempt HTTP 404 from strict upstream error handling on MCP selects Under stackql's database semantics, querying an absent resource legitimately yields a provider 404 and maps onto an empty result set (zero rows), not a query failure. The issue #670 strict handling now exempts 404 so MCP keeps returning {"rows": []} for absence, exactly as the CLI and pgsrv always have; 401/403/408/429/5xx (and other 4xx) still surface as classified tool errors. Applied at the execution layer (not the MCP surface) so join / union legs that 404 keep contributing zero rows without failing the statement. Adds a 404 JSON-object route to the github mock, a robot scenario pinning the empty-result behaviour over MCP, and unit coverage for the not-found message classification. Co-Authored-By: Claude Fable 5 --- .../execution/mono_valent_execution.go | 34 +++++++++++- .../execution/upstream_not_found_test.go | 52 +++++++++++++++++++ .../stackql_test_tooling/flask/github/app.py | 16 ++++++ test/robot/functional/mcp.robot | 25 ++++++++- 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 internal/stackql/execution/upstream_not_found_test.go diff --git a/internal/stackql/execution/mono_valent_execution.go b/internal/stackql/execution/mono_valent_execution.go index cb197123..1a315e80 100644 --- a/internal/stackql/execution/mono_valent_execution.go +++ b/internal/stackql/execution/mono_valent_execution.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strconv" "strings" "time" @@ -40,6 +41,31 @@ var ( MonitorPollIntervalSeconds int = 10 //nolint:revive,gochecknoglobals // TODO: global vars refactor ) +// upstreamStatusCodeRegex matches the status code fragments any-sdk embeds in +// upstream response error strings, 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 + +// isUpstreamNotFound reports whether every upstream error message carries +// 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). +func isUpstreamNotFound(messages []string) bool { + if len(messages) == 0 { + return false + } + for _, message := range messages { + match := upstreamStatusCodeRegex.FindStringSubmatch(message) + if match == nil || match[1] != "404" { + return false + } + } + return true +} + //nolint:gochecknoglobals // TODO: global vars refactor var ( _ MonoValentExecutorFactory = (*monoValentExecution)(nil) @@ -1555,9 +1581,13 @@ func (mv *monoValentExecution) GetExecutor() (func(pc primitive.IPrimitiveCtx) i // 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". + // "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 { + !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, "; ")), diff --git a/internal/stackql/execution/upstream_not_found_test.go b/internal/stackql/execution/upstream_not_found_test.go new file mode 100644 index 00000000..2d10012a --- /dev/null +++ b/internal/stackql/execution/upstream_not_found_test.go @@ -0,0 +1,52 @@ +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", + messages: []string{"something else entirely"}, + want: false, + }, + { + name: "empty messages", + messages: nil, + 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) + } + }) + } +} diff --git a/test/python/stackql_test_tooling/flask/github/app.py b/test/python/stackql_test_tooling/flask/github/app.py index 21541be9..7d2a5ee3 100644 --- a/test/python/stackql_test_tooling/flask/github/app.py +++ b/test/python/stackql_test_tooling/flask/github/app.py @@ -435,6 +435,22 @@ def get_rate_limited_org_repos(): }), 403 +@app.route('/orgs/nonexistentorg/repos', methods=['GET']) +def get_nonexistent_org_repos(): + """Mimics GitHub's absence response: a 404 with a JSON object body. + + Exercised by the MCP robot scenarios for issue #670: querying an absent + resource is database-semantics "zero rows", so a 404 must keep returning + an empty result set (not a tool error), over MCP included. + """ + logger.warning("Not-found GET request for nonexistentorg repos") + return jsonify({ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/repos/repos#list-organization-repositories", + "status": "404", + }), 404 + + @app.route('/orgs/throttledorg/repos', methods=['GET']) def get_throttled_org_repos(): """A 429 with a JSON object body. diff --git a/test/robot/functional/mcp.robot b/test/robot/functional/mcp.robot index 510ed4c1..fa213878 100644 --- a/test/robot/functional/mcp.robot +++ b/test/robot/functional/mcp.robot @@ -1097,8 +1097,11 @@ MCP HTTP Invalid Format Argument Is Rejected # =========================================================================== # Issue #670 scenarios. Upstream HTTP errors on SELECTs must surface as tool # errors with an http_status / retryable classification, not as a successful -# empty result set. The github mock serves 403 for org `ratelimitedorg` and -# 429 for org `throttledorg`, both with JSON object bodies as GitHub sends. +# empty result set. HTTP 404 is the deliberate exception: under stackql's +# database semantics, querying an absent resource is "zero rows", so a 404 +# keeps returning an empty result set. The github mock serves 403 for org +# `ratelimitedorg`, 429 for org `throttledorg` and 404 for org +# `nonexistentorg`, all with JSON object bodies as GitHub sends. # =========================================================================== MCP HTTP Upstream 403 Surfaces As Non Retryable Tool Error @@ -1137,3 +1140,21 @@ MCP HTTP Upstream 429 Surfaces As Retryable Tool Error Should Contain ${result.stderr} upstream http error Should Contain ${result.stderr} "http_status": 429 Should Contain ${result.stderr} "retryable": true + +MCP HTTP Upstream 404 Returns Empty Result Set + [Documentation] Issue #670 scope guard: querying an absent resource (404) + ... is database-semantics "zero rows", so it keeps returning + ... a successful empty result set over MCP, not a tool error. + Pass Execution If "%{IS_SKIP_MCP_TEST=false}" == "true" Some platforms do not have the MCP client available + Sleep 5s + ${result}= Run Process ${STACKQL_MCP_CLIENT_EXE} + ... exec + ... \-\-client\-type\=http + ... \-\-url\=http://127.0.0.1:9912 + ... \-\-exec.action run_select_query + ... \-\-exec.args {"sql": "SELECT id, name FROM github.repos.repos WHERE org \= 'nonexistentorg';"} + ... stdout=${CURDIR}${/}tmp${/}MCP-Upstream-404.txt + ... stderr=${CURDIR}${/}tmp${/}MCP-Upstream-404-stderr.txt + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} "rows":[] + Should Not Contain ${result.stderr} upstream http error From f6e6c423d9c0cf20c664db2a88de96c72dfdb68b Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Fri, 3 Jul 2026 18:18:26 +1000 Subject: [PATCH 3/3] fix: format-agnostic not-found detection for the MCP 404 exemption Upstream error 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. When the structured fragment is present its code is authoritative (a 403 whose body mentions 'not found' is still an error); otherwise absence is detected by a loose, any-case match on a standalone 404 or the phrase 'not found' anywhere in the text. A false positive restores the historical empty-result behaviour, which is the safe direction. Interim measure pending naked status code propagation through the any-sdk invoker boundary (companion issues to follow). Co-Authored-By: Claude Fable 5 --- .../execution/mono_valent_execution.go | 39 +++++++++++++++---- .../execution/upstream_not_found_test.go | 32 ++++++++++++++- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/internal/stackql/execution/mono_valent_execution.go b/internal/stackql/execution/mono_valent_execution.go index 1a315e80..f15a571c 100644 --- a/internal/stackql/execution/mono_valent_execution.go +++ b/internal/stackql/execution/mono_valent_execution.go @@ -41,25 +41,50 @@ var ( MonitorPollIntervalSeconds int = 10 //nolint:revive,gochecknoglobals // TODO: global vars refactor ) -// upstreamStatusCodeRegex matches the status code fragments any-sdk embeds in -// upstream response error strings, eg "HTTP response error. Status code 404." -// and "Response error. Status code 404. Body: ...". A sibling of the +// 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 -// isUpstreamNotFound reports whether every upstream error message carries +// 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 +// "404 Not Found", 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). +// (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 { - match := upstreamStatusCodeRegex.FindStringSubmatch(message) - if match == nil || match[1] != "404" { + if match := upstreamStatusCodeRegex.FindStringSubmatch(message); match != nil { + if match[1] != "404" { + return false + } + continue + } + if !upstreamNotFoundLooseRegex.MatchString(message) { return false } } diff --git a/internal/stackql/execution/upstream_not_found_test.go b/internal/stackql/execution/upstream_not_found_test.go index 2d10012a..88ee877c 100644 --- a/internal/stackql/execution/upstream_not_found_test.go +++ b/internal/stackql/execution/upstream_not_found_test.go @@ -32,7 +32,7 @@ func TestIsUpstreamNotFound(t *testing.T) { want: false, }, { - name: "no status code fragment", + name: "no status code fragment and no not-found phrasing", messages: []string{"something else entirely"}, want: false, }, @@ -41,6 +41,36 @@ func TestIsUpstreamNotFound(t *testing.T) { messages: nil, want: false, }, + { + name: "unstructured go default body", + messages: []string{"404 page not found"}, + want: true, + }, + { + name: "unstructured html body", + messages: []string{"404 Not Found

NOT FOUND

"}, + want: true, + }, + { + name: "unstructured xml fault", + messages: []string{"404The specified key does not exist."}, + 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) {