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
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
# Pin to a patched 1.26 (GO-2026-5856 / CVE-2026-42505 fixed in 1.26.5).
go-version: '1.26.5'
check-latest: true

- name: Verify modules are tidy
Expand All @@ -37,6 +38,11 @@ jobs:
# feedback before the heavy functional jobs run.
run: go vet -tags functional ./test/functional/...

- name: govulncheck
# Fail the PR if any known vulnerability is reachable from our code
# (stdlib and dependencies). Complements go vet / golangci-lint.
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...

- name: golangci-lint
# The action major must match the golangci-lint major (v2 config schema);
# the binary version is pinned for reproducible results across runs.
Expand Down Expand Up @@ -72,7 +78,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
go-version: '1.26.5'
check-latest: true

- name: Functional suite (local; Trino + Presto)
Expand All @@ -87,7 +93,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
go-version: '1.26.5'
check-latest: true

- name: Enterprise functional suite (HTTP + passthrough, containerized image)
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
go-version: '1.26.5'
check-latest: true

- name: Verify dependencies
Expand All @@ -97,7 +97,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
go-version: '1.26.5'
check-latest: true

- name: Functional suite (local; Trino + Presto)
Expand All @@ -112,7 +112,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
go-version: '1.26.5'
check-latest: true

- name: Enterprise functional suite (containerized image)
Expand All @@ -136,7 +136,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.x'
go-version: '1.26.5'
check-latest: true

- name: Build release binaries
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1

# --- build stage ----------------------------------------------------------
FROM golang:1.26 AS build
FROM golang:1.26.5 AS build
WORKDIR /src

# Download modules first so they are cached across source changes.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,12 @@ kubectl apply -f deploy/kubernetes.yaml # ConfigMap + Deployment + Service (pr
The MCP HTTP edge supports TLS, an `Origin` allowlist (DNS-rebinding protection), a
`/healthz` probe, and **optional** bearer verification (`edge_auth.scheme: jwt_rs256`) — the
default is opaque passthrough (forward the token unverified; the engine is the authority).
`edge_auth` is **required** when `transport: http` is paired with any `auth.mode: static`
engine (otherwise the edge would be unauthenticated while holding engine credentials).

## Tech stack

- **Language:** Go 1.26
- **Language:** Go 1.26.5+
- **MCP:** the official [`github.com/modelcontextprotocol/go-sdk`](https://github.com/modelcontextprotocol/go-sdk)
(one codebase covers both stdio and streamable-HTTP)
- **HTTP:** standard-library `net/http` (Presto REST API)
Expand Down
10 changes: 6 additions & 4 deletions config.enterprise.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ server:
# Omit to disable the check (e.g. when fronted by a trusted gateway).
# allowed_origins: ["https://agent.internal"]

# Edge auth is OPTIONAL and defaults to opaque passthrough (the Authorization
# header is forwarded unverified; the engine is the authority). Enable this
# block for defense-in-depth: the MCP verifies the caller's bearer token at
# the edge and forwards the verified subject as the engine user.
# Edge auth is OPTIONAL for passthrough engines (the Authorization header is
# forwarded unverified; the engine is the authority). Enable this block for
# defense-in-depth: the MCP verifies the caller's bearer token at the edge
# and forwards the verified subject as the engine user.
# REQUIRED when any engine uses auth.mode=static over HTTP — otherwise the
# MCP edge would be unauthenticated while holding engine credentials.
# edge_auth:
# scheme: jwt_rs256
# public_key_ref: file:///etc/presto-mcp/jwt-pub.pem
Expand Down
1 change: 1 addition & 0 deletions deploy/kubernetes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ data:
port: 8080
# tls_cert_ref: file:///etc/presto-mcp/tls/tls.crt
# tls_key_ref: file:///etc/presto-mcp/tls/tls.key
# edge_auth is optional for passthrough; required if any engine uses auth.mode=static.
# edge_auth:
# scheme: jwt_rs256
# public_key_ref: file:///etc/presto-mcp/tls/jwt-pub.pem
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/yabinma/presto-mcp

go 1.26.4
go 1.26.5

require (
github.com/modelcontextprotocol/go-sdk v1.6.1
Expand Down
27 changes: 27 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,33 @@ func (c *Config) Validate() error {
}
seen[id] = true
}
// HTTP + static credentials without edge auth would expose the service
// account to any network client that can reach the MCP port.
if c.Server.Transport == TransportHTTP {
if err := c.validateHTTPStaticAuth(); err != nil {
return err
}
}
return nil
}

// validateHTTPStaticAuth requires edge bearer verification whenever the HTTP
// transport is paired with a static engine credential. Without it the server
// would hold a secret and serve unauthenticated tool access.
func (c *Config) validateHTTPStaticAuth() error {
hasStatic := false
for i := range c.Engines {
if c.Engines[i].Auth.Mode == AuthStatic {
hasStatic = true
break
}
}
if !hasStatic {
return nil
}
if c.Server.HTTP == nil || c.Server.HTTP.EdgeAuth == nil {
return fmt.Errorf("server.http.edge_auth: required when transport is http and any engine uses auth.mode=static (otherwise the MCP edge is unauthenticated while holding engine credentials)")
}
return nil
}

Expand Down
56 changes: 56 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ engines: [{id: e, endpoint: http://h:1, auth: {mode: passthrough}}]`,
deployment_mode: enterprise
server: {transport: http, http: {port: 8080, edge_auth: {scheme: jwt_rs256}}}
engines: [{id: e, endpoint: http://h:1, auth: {mode: passthrough}}]`,
"http static without edge auth": `
deployment_mode: enterprise
server: {transport: http, http: {port: 8080}}
engines: [{id: e, endpoint: https://h:1, auth: {mode: static, credential_ref: env://TOK}}]`,
}
for name, doc := range cases {
t.Run(name, func(t *testing.T) {
Expand All @@ -146,6 +150,58 @@ engines: [{id: e, endpoint: http://h:1, auth: {mode: passthrough}}]`,
}
}

func TestParse_HTTPStaticRequiresEdgeAuth(t *testing.T) {
t.Run("rejected without edge_auth", func(t *testing.T) {
_, err := Parse([]byte(`
deployment_mode: enterprise
server:
transport: http
http: { host: 0.0.0.0, port: 8080 }
engines:
- id: e
endpoint: https://trino:8443
auth: { mode: static, credential_ref: env://TOK }
`))
require.Error(t, err)
assert.Contains(t, err.Error(), "edge_auth")
})

t.Run("accepted with edge_auth", func(t *testing.T) {
cfg, err := Parse([]byte(`
deployment_mode: enterprise
server:
transport: http
http:
host: 0.0.0.0
port: 8080
edge_auth:
scheme: jwt_rs256
public_key_ref: file:///etc/presto-mcp/jwt-pub.pem
engines:
- id: e
endpoint: https://trino:8443
auth: { mode: static, credential_ref: env://TOK }
`))
require.NoError(t, err)
assert.Equal(t, AuthStatic, cfg.Engines[0].Auth.Mode)
require.NotNil(t, cfg.Server.HTTP.EdgeAuth)
})

t.Run("passthrough without edge_auth is fine", func(t *testing.T) {
_, err := Parse([]byte(`
deployment_mode: enterprise
server:
transport: http
http: { host: 0.0.0.0, port: 8080 }
engines:
- id: e
endpoint: https://trino:8443
auth: { mode: passthrough }
`))
require.NoError(t, err)
})
}

func TestParse_SchemeDefaultsToBearer(t *testing.T) {
cfg, err := Parse([]byte(validLocal))
require.NoError(t, err)
Expand Down
73 changes: 69 additions & 4 deletions internal/presto/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -49,7 +51,14 @@ func NewClient(eng config.EngineConfig, cred credential.Provider, opts ...Option
if cred == nil {
return nil, fmt.Errorf("credential provider is required")
}
httpClient := &http.Client{Timeout: 30 * time.Second}
httpClient := &http.Client{
Timeout: 30 * time.Second,
// Never follow redirects: a malicious nextUri or engine response must
// not bounce credentials to a third host.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
if eng.TLSInsecureSkipVerify {
tr := http.DefaultTransport.(*http.Transport).Clone()
// Dev/test only: trust self-signed certificates for this engine.
Expand Down Expand Up @@ -171,15 +180,22 @@ func (c *Client) collect(ctx context.Context, sql, catalog, schema string, maxRo
if maxRows > 0 && len(rows) > maxRows {
rows = rows[:maxRows]
if sr.NextURI != "" {
c.cancelStatement(ctx, sr.NextURI, cred)
if next, err := c.allowedNextURI(sr.NextURI); err == nil {
c.cancelStatement(ctx, next, cred)
}
}
return cols, rows, true, nil
}
if sr.NextURI == "" {
return cols, rows, false, nil
}
// Follow the next page. Trino requires consuming nextUri to completion.
req, err = http.NewRequestWithContext(ctx, http.MethodGet, sr.NextURI, nil)
// Only same-origin URIs are accepted to prevent SSRF / credential exfil.
next, err := c.allowedNextURI(sr.NextURI)
if err != nil {
return nil, nil, false, err
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, nil, false, err
}
Expand Down Expand Up @@ -410,18 +426,67 @@ func (c *Client) GetQuery(ctx context.Context, queryID string) (*QueryInfo, erro
// GetQueryRaw returns the raw /v1/query/{id} response body, used when the caller
// asked for the raw fragment (raw=true) so it can be both decoded and attached.
func (c *Client) GetQueryRaw(ctx context.Context, queryID string) ([]byte, error) {
if err := validateQueryID(queryID); err != nil {
return nil, err
}
cred, err := c.cred.Resolve(ctx)
if err != nil {
return nil, fmt.Errorf("resolve credential: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+"/v1/query/"+queryID, nil)
// Path-escape so query_id cannot alter the path or inject query/fragment.
path := c.endpoint + "/v1/query/" + url.PathEscape(queryID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
c.dialect.apply(req, cred, "", "", c.source)
return c.do(req)
}

// queryIDPattern matches Presto/Trino-style query ids (alphanumeric with common
// separators). Anything outside this set is rejected to block path injection.
var queryIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.:-]+$`)

const maxQueryIDLen = 128

// validateQueryID rejects empty, oversized, or structurally unsafe query ids.
func validateQueryID(queryID string) error {
if queryID == "" {
return fmt.Errorf("query_id is required")
}
if len(queryID) > maxQueryIDLen {
return fmt.Errorf("query_id is too long")
}
if !queryIDPattern.MatchString(queryID) {
return fmt.Errorf("query_id contains invalid characters")
}
return nil
}

// allowedNextURI returns nextURI if it targets the same scheme and host as the
// configured engine endpoint; otherwise it errors. This blocks SSRF and
// credential forwarding to attacker-controlled hosts via malicious nextUri.
func (c *Client) allowedNextURI(nextURI string) (string, error) {
if nextURI == "" {
return "", fmt.Errorf("empty nextUri")
}
next, err := url.Parse(nextURI)
if err != nil {
return "", fmt.Errorf("nextUri: %w", err)
}
if next.Scheme == "" || next.Host == "" {
return "", fmt.Errorf("nextUri: must be an absolute URL")
}
base, err := url.Parse(c.endpoint)
if err != nil {
return "", fmt.Errorf("endpoint: %w", err)
}
if !strings.EqualFold(next.Scheme, base.Scheme) || !strings.EqualFold(next.Host, base.Host) {
return "", fmt.Errorf("nextUri host %q is not the configured engine %q", next.Host, base.Host)
}
return next.String(), nil
}

// DecodeQueryInfo decodes a /v1/query/{id} response body into QueryInfo.
func DecodeQueryInfo(body []byte) (*QueryInfo, error) {
var qi QueryInfo
Expand Down
Loading