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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module github.com/txn2/mcp-s3

go 1.25.0

toolchain go1.26.4

require (
github.com/aws/aws-sdk-go-v2 v1.41.9
github.com/aws/aws-sdk-go-v2/config v1.32.20
Expand Down
15 changes: 13 additions & 2 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,19 @@ func New(ctx context.Context, cfg *Config) (*Client, error) {
// Create S3 client
s3Client := s3.NewFromConfig(awsCfg, s3Opts...)

// Create presign client
presignClient := s3.NewPresignClient(s3Client)
// Create presign client. When a dedicated presign endpoint is configured,
// sign URLs against a client pointed at that public-facing endpoint so the
// URLs are reachable outside the cluster; data operations keep using
// s3Client (the internal Endpoint). With no presign endpoint the two are the
// same client, preserving prior behavior.
presignSource := s3Client
if cfg.PresignEndpoint != "" {
presignSource = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.PresignEndpoint)
o.UsePathStyle = cfg.UsePathStyle
})
}
presignClient := s3.NewPresignClient(presignSource)

// Create the streaming/multipart uploader. It shares the same underlying
// S3 client so it honors the configured endpoint, credentials, and region.
Expand Down
54 changes: 54 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
neturl "net/url"
"os"
"strings"
"testing"
Expand Down Expand Up @@ -720,6 +721,59 @@ func TestClient_PresignGetURL(t *testing.T) {
})
}

// TestNew_PresignEndpoint verifies that a configured PresignEndpoint signs
// presigned URLs against the public endpoint while data operations keep using
// Endpoint, and that an empty PresignEndpoint falls back to Endpoint. Presigning
// is a local operation (no network), so a real client built via New is used.
func TestNew_PresignEndpoint(t *testing.T) {
ctx := context.Background()

cfgWith := func(presign string) *Config {
return &Config{
Region: "us-east-1",
Endpoint: "http://internal:8333",
PresignEndpoint: presign,
AccessKeyID: "test",
SecretAccessKey: "secret",
UsePathStyle: true,
}
}
presignHost := func(t *testing.T, c *Client) (scheme, host string) {
t.Helper()
got, err := c.PresignGetURL(ctx, "my-bucket", "file.txt", time.Hour)
if err != nil {
t.Fatalf("PresignGetURL: %v", err)
}
u, err := neturl.Parse(got.URL)
if err != nil {
t.Fatalf("parse %q: %v", got.URL, err)
}
return u.Scheme, u.Host
}

t.Run("signs against the public presign endpoint", func(t *testing.T) {
c, err := New(ctx, cfgWith("https://s3.public.example.com"))
if err != nil {
t.Fatalf("New: %v", err)
}
scheme, host := presignHost(t, c)
if scheme != "https" || host != "s3.public.example.com" {
t.Errorf("presigned URL = %s://%s, want https://s3.public.example.com", scheme, host)
}
})

t.Run("empty presign endpoint falls back to the data endpoint", func(t *testing.T) {
c, err := New(ctx, cfgWith(""))
if err != nil {
t.Fatalf("New: %v", err)
}
_, host := presignHost(t, c)
if host != "internal:8333" {
t.Errorf("presigned URL host = %s, want internal:8333", host)
}
})
}

func TestClient_PresignPutURL(t *testing.T) {
t.Run("success", func(t *testing.T) {
presignMock := &mockPresignAPI{
Expand Down
10 changes: 10 additions & 0 deletions pkg/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ type Config struct {
// Endpoint is an optional custom endpoint URL for S3-compatible services (SeaweedFS, LocalStack, etc.).
Endpoint string

// PresignEndpoint is an optional public-facing endpoint used only when
// signing presigned URLs. When set, presigned URLs are signed against it so
// they are reachable from outside the cluster, while data operations keep
// using Endpoint (typically an internal/cluster address). Empty falls back
// to Endpoint, preserving prior behavior.
PresignEndpoint string

// AccessKeyID is the AWS access key ID. If empty, the SDK credential chain is used.
AccessKeyID string

Expand Down Expand Up @@ -57,6 +64,7 @@ type Config struct {
// - AWS_SESSION_TOKEN: Session token (optional)
// - AWS_PROFILE: Profile name (optional)
// - S3_ENDPOINT: Custom endpoint URL (optional)
// - S3_PRESIGN_ENDPOINT: Public endpoint for presigned URLs (optional)
// - S3_USE_PATH_STYLE: Use path-style URLs (default: false)
// - S3_TIMEOUT: Operation timeout (default: 30s)
// - S3_CONNECTION_NAME: Connection name (optional)
Expand All @@ -65,6 +73,7 @@ func FromEnv() Config {
cfg := Config{
Region: getEnvOrDefault("AWS_REGION", DefaultRegion),
Endpoint: getEnvSanitized("S3_ENDPOINT"),
PresignEndpoint: getEnvSanitized("S3_PRESIGN_ENDPOINT"),
AccessKeyID: getEnvSanitized("AWS_ACCESS_KEY_ID"),
SecretAccessKey: getEnvSanitized("AWS_SECRET_ACCESS_KEY"),
SessionToken: getEnvSanitized("AWS_SESSION_TOKEN"),
Expand Down Expand Up @@ -107,6 +116,7 @@ func (c *Config) Clone() *Config {
return &Config{
Region: c.Region,
Endpoint: c.Endpoint,
PresignEndpoint: c.PresignEndpoint,
AccessKeyID: c.AccessKeyID,
SecretAccessKey: c.SecretAccessKey,
SessionToken: c.SessionToken,
Expand Down
Loading