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
6 changes: 3 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,9 @@
"id": "mcpSrvCfgStr",
"description": "MCP Server Configuration",
"options": [
"{\"server\": {\"transport\": \"http\", \"address\": \"127.0.0.1:9992\"} }"
"{\"server\": {\"transport\": \"http\", \"address\": \"127.0.0.1:9992\", \"allow_unauthenticated\": true} }"
],
"default": "{\"server\": {\"transport\": \"http\", \"address\": \"127.0.0.1:9992\"} }"
"default": "{\"server\": {\"transport\": \"http\", \"address\": \"127.0.0.1:9992\", \"allow_unauthenticated\": true} }"
},
{
"type": "pickString",
Expand Down Expand Up @@ -548,7 +548,7 @@
"--export.alias=${input:exportAliasString}",
"--pgsrv.debug.enable=${input:serverDebugPublish}",
"--mcp.server.type=reverse_proxy",
"--mcp.config={\"server\": {\"transport\": \"http\", \"address\": \"127.0.0.1:9914\"}, \"backend\": {\"dsn\": \"postgres://stackql:stackql@127.0.0.1:5885?default_query_exec_mode=simple_protocol&standard_conforming_strings=on&client_encoding=UTF8\"}}"
"--mcp.config={\"server\": {\"transport\": \"http\", \"address\": \"127.0.0.1:9914\", \"allow_unauthenticated\": true}, \"backend\": {\"dsn\": \"postgres://stackql:stackql@127.0.0.1:5885?default_query_exec_mode=simple_protocol&standard_conforming_strings=on&client_encoding=UTF8\"}}"
],
},
{
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ StackQL can be used via the interactive REPL shell, or via the `exec` command or
* Server
```sh
# serve client requests over the Postgres wire protocol (psycopg2, etc.)
stackql srv --auth="${AUTH}"
# clients are not authenticated unless mTLS is configured via --pgsrv.tls, so keep the listener on loopback otherwise
stackql srv --pgsrv.address=127.0.0.1 --auth="${AUTH}"
```

_For more examples, please check our [Blog][blog]_
Expand Down
16 changes: 8 additions & 8 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ We have a nice debug config for running an MCP server with `vscode`, please see

```bash

./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992"} }'
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true} }'

```

Expand All @@ -26,23 +26,23 @@ Read-only (mutations and lifecycle refused immediately):

```bash

./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "mode": "read_only"} }'
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true, "mode": "read_only"} }'

```

Delete-safe (INSERTs/UPDATEs proceed; DELETEs and EXECs need approval):

```bash

./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "mode": "delete_safe"} }'
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true, "mode": "delete_safe"} }'

```

Full access (everything proceeds without approval - use only when the client and operator are trusted):

```bash

./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "mode": "full_access"} }'
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true, "mode": "full_access"} }'

```

Expand All @@ -54,7 +54,7 @@ Audit is on by default. To opt out:

```bash

./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "audit": {"disabled": true}} }'
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true, "audit": {"disabled": true}} }'

```

Expand Down Expand Up @@ -210,7 +210,7 @@ Transport behaviour:
- **Streamable HTTP** defaults to the stateful, session-per-client model (`Mcp-Session-Id`), which the SDK serves for revisions up to `2025-11-25`; a `2026-07-28` request is answered with JSON-RPC error `-32022` (`UnsupportedProtocolVersion`, SDK v1.8.0; a plain HTTP 400 before) whose `data.supported` lists the handshake revisions, and `server/discover` advertises the same, so the client negotiates down and existing HTTP hosts keep their sessions and elicitation unchanged. Set `"stateless": true` in `server` (or pin `protocol_version` to `2026-07-28`, below) to serve `2026-07-28` natively: no `Mcp-Session-Id` is issued or read, `GET` and `DELETE` answer 405 (there is no session to tear down), `tools/list` / `prompts/list` / `resources/list` are connection-invariant, and gated writes use the input-required round trip. A sessionless server still accepts a legacy `initialize` and serves reads to that client, but cannot retain the elicitation capability a legacy client declared at initialise (the SDK gives each request an ephemeral session), so legacy HTTP clients cannot approve gated writes on it. Pick stateless for current-revision hosts, stateful for a mixed legacy fleet.

```bash
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "stateless": true} }'
./build/stackql mcp --mcp.server.type=http --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true, "stateless": true} }'
```

The server holds no cross-call state: mode, audit and provider auth are process-level configuration, so nothing needed to move behind explicit handles (SEP-2567). The robot suite drives stdio and both HTTP models with a 2025-06-18 handshake client and a 2026-07-28 stateless client, including the gated write on each revision.
Expand All @@ -220,7 +220,7 @@ The server holds no cross-call state: mode, audit and provider auth are process-
By default the server advertises every revision the SDK supports and negotiates per client. To narrow that:

```bash
./build/stackql mcp --mcp.server.type=http --mcp.protocol.version=2026-07-28 --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992"} }'
./build/stackql mcp --mcp.server.type=http --mcp.protocol.version=2026-07-28 --mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true} }'
./build/stackql mcp --mcp.server.type=stdio --mcp.protocol.version=2025-11-25
```

Expand Down Expand Up @@ -271,7 +271,7 @@ The only sink shipped in this release is `file`. One JSON object per line, fsyn
```bash
./build/stackql mcp \
--mcp.server.type=http \
--mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "audit": {"file": {"path": "/var/log/stackql-mcp.log", "max_size_mb": 100, "max_backups": 5, "max_age_days": 30}}} }'
--mcp.config '{"server": {"transport": "http", "address": "127.0.0.1:9992", "allow_unauthenticated": true, "audit": {"file": {"path": "/var/log/stackql-mcp.log", "max_size_mb": 100, "max_backups": 5, "max_age_days": 30}}} }'
```

Two ways to specify the location:
Expand Down
39 changes: 31 additions & 8 deletions internal/stackql/mcpbackend/mcp_service_stackql.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ var (

const (
unlimitedRowLimit int = -1
// forbiddenRegistryCharacters mirrors the CLI registry command's guard
// (see internal/stackql/cmd/registry.go). Interrogator methods that
// interpolate user-supplied registry identifiers reject these characters
// rather than substituting / escaping them, matching CLI semantics.
forbiddenRegistryCharacters string = ` ;\`
// forbiddenIdentifierCharacters extends the CLI registry command's guard
// (see internal/stackql/cmd/registry.go) to all whitespace. Interrogator
// methods that interpolate user-supplied identifiers reject these
// characters rather than substituting / escaping them.
forbiddenIdentifierCharacters string = " ;\\\t\r\n"
)

// serverBuildInfo carries the runtime + build-time metadata reported by the
Expand Down Expand Up @@ -110,6 +110,17 @@ func NewSimpleStackqlInterrogator() StackqlInterrogator {
return &simpleStackqlInterrogator{}
}

// checkHierarchyIdentifiers stops a second statement riding in on an
// interpolated hierarchy identifier.
func checkHierarchyIdentifiers(hI dto.HierarchyInput) error {
for _, identifier := range []string{hI.Provider, hI.Service, hI.Resource, hI.Method} {
if strings.ContainsAny(identifier, forbiddenIdentifierCharacters) {
return fmt.Errorf("forbidden characters in provider, service, resource or method")
}
}
return nil
}

func (s *simpleStackqlInterrogator) GetShowProviders(_ dto.HierarchyInput, likeStr string) (string, error) {
sb := strings.Builder{}
sb.WriteString("SHOW PROVIDERS")
Expand All @@ -125,6 +136,9 @@ func (s *simpleStackqlInterrogator) GetShowServices(hI dto.HierarchyInput, likeS
if hI.Provider == "" {
return "", fmt.Errorf("provider not specified")
}
if err := checkHierarchyIdentifiers(hI); err != nil {
return "", err
}
sb := strings.Builder{}
sb.WriteString("SHOW SERVICES IN ")
sb.WriteString(hI.Provider)
Expand All @@ -140,6 +154,9 @@ func (s *simpleStackqlInterrogator) GetShowResources(hI dto.HierarchyInput, like
if hI.Provider == "" || hI.Service == "" {
return "", fmt.Errorf("provider and / or service not specified")
}
if err := checkHierarchyIdentifiers(hI); err != nil {
return "", err
}
sb := strings.Builder{}
sb.WriteString("SHOW RESOURCES IN ")
sb.WriteString(hI.Provider)
Expand All @@ -157,6 +174,9 @@ func (s *simpleStackqlInterrogator) GetShowMethods(hI dto.HierarchyInput) (strin
if hI.Provider == "" || hI.Service == "" || hI.Resource == "" {
return "", fmt.Errorf("provider, service and / or resource not specified")
}
if err := checkHierarchyIdentifiers(hI); err != nil {
return "", err
}
sb := strings.Builder{}
sb.WriteString("SHOW METHODS IN ")
sb.WriteString(hI.Provider)
Expand All @@ -171,6 +191,9 @@ func (s *simpleStackqlInterrogator) GetDescribeMethod(hI dto.HierarchyInput) (st
if hI.Provider == "" || hI.Service == "" || hI.Resource == "" || hI.Method == "" {
return "", fmt.Errorf("provider, service, resource and / or method not specified")
}
if err := checkHierarchyIdentifiers(hI); err != nil {
return "", err
}
sb := strings.Builder{}
sb.WriteString("DESCRIBE METHOD EXTENDED ")
sb.WriteString(hI.Provider)
Expand All @@ -191,7 +214,7 @@ func (s *simpleStackqlInterrogator) GetQueryJSON(qI dto.QueryJSONInput) (string,
}

func (s *simpleStackqlInterrogator) GetRegistryList(provider string) (string, error) {
if provider != "" && strings.ContainsAny(provider, forbiddenRegistryCharacters) {
if provider != "" && strings.ContainsAny(provider, forbiddenIdentifierCharacters) {
return "", fmt.Errorf("forbidden characters in provider")
}
sb := strings.Builder{}
Expand All @@ -208,8 +231,8 @@ func (s *simpleStackqlInterrogator) GetRegistryPull(provider, version string) (s
if provider == "" {
return "", fmt.Errorf("provider not specified")
}
if strings.ContainsAny(provider, forbiddenRegistryCharacters) ||
strings.ContainsAny(version, forbiddenRegistryCharacters) {
if strings.ContainsAny(provider, forbiddenIdentifierCharacters) ||
strings.ContainsAny(version, forbiddenIdentifierCharacters) {
return "", fmt.Errorf("forbidden characters in provider or version")
}
sb := strings.Builder{}
Expand Down
35 changes: 29 additions & 6 deletions internal/stackql/paramdecoder/paramdecoder.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a nullable type is needed on the return value line items then create an abstraction. You are changing the behaviour in terms of response cardinality and i see no justification given the function signature and prior behaviour.

Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,33 @@ import (
"github.com/lib/pq/oid"
)

// DecodedParam is one decoded parameter. SQL NULL is flagged rather than
// inferred from the value, so a client string "NULL" stays a string.
type DecodedParam interface {
GetValue() string
IsNull() bool
}

type standardDecodedParam struct {
value string
isNull bool
}

func newDecodedParam(value string) DecodedParam {
return &standardDecodedParam{value: value}
}

func newNullDecodedParam() DecodedParam {
return &standardDecodedParam{value: "NULL", isNull: true}
}

func (p *standardDecodedParam) GetValue() string { return p.value }
func (p *standardDecodedParam) IsNull() bool { return p.isNull }

// Decoder decodes raw parameter bytes according to their format codes
// and OIDs, returning string representations for each.
// and OIDs, returning one DecodedParam for each.
type Decoder interface {
DecodeParams(paramOIDs []uint32, paramFormats []int16, paramValues [][]byte) ([]string, error)
DecodeParams(paramOIDs []uint32, paramFormats []int16, paramValues [][]byte) ([]DecodedParam, error)
}

// NewDecoder creates a new parameter decoder.
Expand All @@ -27,11 +50,11 @@ type standardDecoder struct{}

func (d *standardDecoder) DecodeParams(
paramOIDs []uint32, paramFormats []int16, paramValues [][]byte,
) ([]string, error) {
result := make([]string, len(paramValues))
) ([]DecodedParam, error) {
result := make([]DecodedParam, len(paramValues))
for i, val := range paramValues {
if val == nil {
result[i] = "NULL"
result[i] = newNullDecodedParam()
continue
}
format := resolveFormat(paramFormats, i)
Expand All @@ -43,7 +66,7 @@ func (d *standardDecoder) DecodeParams(
if err != nil {
return nil, fmt.Errorf("parameter $%d: %w", i+1, err)
}
result[i] = decoded
result[i] = newDecodedParam(decoded)
}
return result, nil
}
Expand Down
41 changes: 28 additions & 13 deletions internal/stackql/paramdecoder/paramdecoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestDecodeTextParams(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "hello" || results[1] != "world" {
if results[0].GetValue() != "hello" || results[1].GetValue() != "world" {
t.Errorf("got %v", results)
}
}
Expand All @@ -34,8 +34,23 @@ func TestDecodeNullParam(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "NULL" {
t.Errorf("got %q, want NULL", results[0])
if !results[0].IsNull() || results[0].GetValue() != "NULL" {
t.Errorf("got %q (null %t), want NULL flagged null", results[0].GetValue(), results[0].IsNull())
}
}

func TestDecodeTextNullStringIsNotNull(t *testing.T) {
d := paramdecoder.NewDecoder()
results, err := d.DecodeParams(
[]uint32{uint32(oid.T_text)},
nil,
[][]byte{[]byte("NULL")},
)
if err != nil {
t.Fatal(err)
}
if results[0].IsNull() || results[0].GetValue() != "NULL" {
t.Errorf("got %q (null %t), want the string NULL", results[0].GetValue(), results[0].IsNull())
}
}

Expand All @@ -51,8 +66,8 @@ func TestDecodeBinaryInt4(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "42" {
t.Errorf("got %q, want 42", results[0])
if results[0].GetValue() != "42" {
t.Errorf("got %q, want 42", results[0].GetValue())
}
}

Expand All @@ -68,8 +83,8 @@ func TestDecodeBinaryInt8(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "9999999999" {
t.Errorf("got %q, want 9999999999", results[0])
if results[0].GetValue() != "9999999999" {
t.Errorf("got %q, want 9999999999", results[0].GetValue())
}
}

Expand All @@ -85,8 +100,8 @@ func TestDecodeBinaryFloat8(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "3.14" {
t.Errorf("got %q, want 3.14", results[0])
if results[0].GetValue() != "3.14" {
t.Errorf("got %q, want 3.14", results[0].GetValue())
}
}

Expand All @@ -100,7 +115,7 @@ func TestDecodeBinaryBool(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "true" || results[1] != "false" {
if results[0].GetValue() != "true" || results[1].GetValue() != "false" {
t.Errorf("got %v", results)
}
}
Expand All @@ -117,7 +132,7 @@ func TestDecodeMixedFormats(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "hello" || results[1] != "100" {
if results[0].GetValue() != "hello" || results[1].GetValue() != "100" {
t.Errorf("got %v", results)
}
}
Expand All @@ -132,7 +147,7 @@ func TestDecodeUnknownOIDBinaryFallsBackToText(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if results[0] != "raw-bytes" {
t.Errorf("got %q, want raw-bytes", results[0])
if results[0].GetValue() != "raw-bytes" {
t.Errorf("got %q, want raw-bytes", results[0].GetValue())
}
}
Loading
Loading