Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ To serve over HTTP and take the S3M token from each incoming request's `Authoriz
s3m mcp --http --stateless-auth
```

The public S3M MCP server is available at `https://s3m.olcf.ornl.gov/olcf/mcp`. Until it receives a token with usable S3M service scopes, it only exposes one tool describing how to create an S3M token in [myOLCF](https://my.olcf.ornl.gov/) and configure the server. Create a token with the scopes for the S3M services you want to use, then configure your MCP client to connect to the endpoint with `Authorization: <token>`.

The S3M CLI also serves documentation tools via MCP and OpenAPI, to help AI agents understand how to use the other tools. These are populated from a docs archive fetched over HTTP (`--docs-url`, default the public `olcf/s3m-mcp-docs` archive).


Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.3.0
2 changes: 1 addition & 1 deletion build/containers/deployment/release.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7

ARG GO_VERSION=1.27.1
ARG GO_VERSION=1.27.2
FROM golang:${GO_VERSION} AS builder

WORKDIR /src
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/olcf/s3m-cli

go 1.27.1
go 1.27.2

require (
github.com/jedib0t/go-pretty/v6 v6.8.3
Expand All @@ -20,7 +20,7 @@ require (
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
Expand Down
32 changes: 0 additions & 32 deletions internal/cmd/servercmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,38 +168,6 @@ func prepareStatelessConnection(
return allowed, conn, nil
}

func wrapWithAuthTokenExtraction(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
handler.ServeHTTP(w, r)
return
}

authorization := r.Header.Get("Authorization")
if len(authorization) > maxAuthorizationHeaderBytes {
http.Error(w, "Authorization header too large", http.StatusRequestHeaderFieldsTooLarge)

return
}

token := grpcclient.TokenFromAuthorizationHeader(authorization)

logAuthTokenExtraction(r, token)

if token == "" {
logMissingAuthorization(r)
http.Error(w, "missing Authorization header", http.StatusUnauthorized)

return
}

ctx := grpcclient.ContextWithAuthToken(r.Context(), token)
handler.ServeHTTP(w, r.WithContext(ctx))
})
}

const maxAuthorizationHeaderBytes = 16 << 10

func registerStatelessOpenAPIHandlers(
mux *http.ServeMux,
routes []toolset.HTTPRouteSpec,
Expand Down
87 changes: 0 additions & 87 deletions internal/cmd/servercmd/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,93 +100,6 @@ func TestHasAllowedMethodNames(t *testing.T) {
}
}

func TestWrapWithAuthTokenExtractionSuccess(t *testing.T) {
var capturedToken string

inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := grpcclient.AuthTokenFromContext(r.Context())
if ok {
capturedToken = token
}

w.WriteHeader(http.StatusOK)
})

handler := wrapWithAuthTokenExtraction(inner)

req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rr.Code)
}

if capturedToken != "my-secret-token" {
t.Fatalf("expected token 'my-secret-token', got %q", capturedToken)
}
}

func TestWrapWithAuthTokenExtractionMissingHeader(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("inner handler should not be called")
})

handler := wrapWithAuthTokenExtraction(inner)

req := httptest.NewRequest(http.MethodPost, "/", nil)

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rr.Code)
}
}

func TestWrapWithAuthTokenExtractionRejectsOversizedHeader(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("inner handler should not be called")
})

handler := wrapWithAuthTokenExtraction(inner)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", strings.Repeat("x", maxAuthorizationHeaderBytes+1))

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusRequestHeaderFieldsTooLarge {
t.Fatalf("expected status 431, got %d", rr.Code)
}
}

func TestWrapWithAuthTokenExtractionOptionsPassthrough(t *testing.T) {
called := false

inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})

handler := wrapWithAuthTokenExtraction(inner)

req := httptest.NewRequest(http.MethodOptions, "/", nil)

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if !called {
t.Fatal("OPTIONS request should pass through without auth check")
}

if rr.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rr.Code)
}
}

func TestWrapStatelessOpenAPIRouteAllowsAuthorizedMethod(t *testing.T) {
file := slurmv0042pb.File_proto_slurm_v0042_slurm_proto
service := file.Services().ByName("SlurmIndirect")
Expand Down
15 changes: 0 additions & 15 deletions internal/cmd/servercmd/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,6 @@ func truncateForLog(value string) string {
return sanitizeLogValue(value[:8]) + "..."
}

func logAuthTokenExtraction(r *http.Request, token string) {
slog.LogAttrs(r.Context(), slog.LevelDebug, "wrapWithAuthTokenExtraction",
safeStringAttr("method", r.Method),
safeStringAttr("path", r.URL.Path),
slog.Bool("hasToken", token != ""),
safeStringAttr("tokenPrefix", truncateForLog(token)),
)
}

func logMissingAuthorization(r *http.Request) {
slog.LogAttrs(r.Context(), slog.LevelWarn, "Rejecting request: missing Authorization header",
safeStringAttr("path", r.URL.Path),
)
}

func logMCPServerFactory(r *http.Request, token string) {
slog.LogAttrs(r.Context(), slog.LevelDebug, "MCP server factory called",
slog.Bool("hasToken", token != ""),
Expand Down
122 changes: 96 additions & 26 deletions internal/cmd/servercmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ func BuildMCPCommand(rt *runtime.Runtime) *cli.Command {
Usage: "Use S3M token from Authorization header for MCP over HTTP and OpenAPI servers"},
&cli.BoolFlag{Name: "stdio",
Usage: "Also serve MCP over STDIO (default when --http is not set)"},
}, docFlags()...)
}, tokenValidationFlags()...)
flags = append(flags, docFlags()...)

return &cli.Command{
Name: "mcp",
Expand Down Expand Up @@ -77,23 +78,7 @@ func BuildMCPCommand(rt *runtime.Runtime) *cli.Command {

defer closeConn(conn)

cache := newTokenCache(rt, conn)
handler := newStatelessMCPHTTPHandler(rt, conn, cache)

addr := c.String("http-addr")
slog.Info("listening for MCP HTTP (stateless, per-token filtering)", "addr", addr)

srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}

// no STDIO in stateless mode
return srv.ListenAndServe()
return serveStatelessMCPHTTP(rt, c, conn)
}

// Non-stateless mode: single shared server
Expand Down Expand Up @@ -161,6 +146,28 @@ func BuildMCPCommand(rt *runtime.Runtime) *cli.Command {
}
}

func serveStatelessMCPHTTP(rt *runtime.Runtime, c *cli.Command, conn *grpc.ClientConn) error {
cache, err := newConfiguredTokenCache(rt, c, conn)
if err != nil {
return err
}

handler := newStatelessMCPHTTPHandler(rt, conn, cache)
addr := c.String("http-addr")
slog.Info("listening for MCP HTTP (stateless, per-token filtering)", "addr", addr)

srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}

return srv.ListenAndServe()
}

func newStatelessMCPHTTPHandler(
rt *runtime.Runtime,
conn *grpc.ClientConn,
Expand Down Expand Up @@ -190,6 +197,12 @@ func newStatelessMCPHTTPHandler(
})

requestHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Get("Authorization")) > maxAuthorizationHeaderBytes {
http.Error(w, "Authorization header too large", http.StatusRequestHeaderFieldsTooLarge)

return
}

if !validStatelessMCPRequest(w, r) {
return
}
Expand All @@ -199,9 +212,11 @@ func newStatelessMCPHTTPHandler(
streamableHandler.ServeHTTP(w, r.WithContext(ctx))
})

return wrapWithAuthTokenExtraction(requestHandler)
return requestHandler
}

const maxAuthorizationHeaderBytes = 16 << 10

func buildStatelessMCPServer(
rt *runtime.Runtime,
conn *grpc.ClientConn,
Expand All @@ -212,16 +227,29 @@ func buildStatelessMCPServer(

logMCPServerFactory(r, token)

perms := cache.GetPermissions(r.Context(), token)
allowed := filterAllowed(perms, rt.Methods)

ts := buildToolSet(rt, allowed, conn, cache.Vars)

srv := mcp.NewServer(&mcp.Implementation{
Name: "s3m",
Version: buildinfo.Version,
}, nil)

perms, introspectionErr := cache.getPermissions(r.Context(), token)
if !perms.Known {
fallback := toolset.BuildUnauthenticatedToolSet()
if introspectionErr != nil {
fallback = toolset.BuildAuthenticationErrorToolSet()
}

for _, spec := range fallback.MCP {
srv.AddTool(spec.Tool, spec.Handler)
}

return srv
}

allowed := filterAllowed(perms, rt.Methods)

ts := buildToolSet(rt, allowed, conn, cache.Vars)

for _, spec := range ts.MCP {
originalHandler := spec.Handler
capturedToken := token
Expand Down Expand Up @@ -285,7 +313,8 @@ func BuildOpenAPICommand(rt *runtime.Runtime) *cli.Command {
&cli.StringFlag{Name: "addr", Value: cmd.DefaultOpenAPIAddr, Usage: "Address to bind"},
&cli.BoolFlag{Name: "stateless-auth",
Usage: "Use stateless auth: require S3M token from Authorization header and do not require a stored login"},
}, docFlags()...)
}, tokenValidationFlags()...)
flags = append(flags, docFlags()...)

return &cli.Command{
Name: "openapi",
Expand Down Expand Up @@ -319,7 +348,11 @@ func BuildOpenAPICommand(rt *runtime.Runtime) *cli.Command {

defer closeConn(conn)

cache := newTokenCache(rt, conn)
cache, err := newConfiguredTokenCache(rt, c, conn)
if err != nil {
return err
}

ts := buildToolSet(rt, allowed, conn, cache.Vars)

mux := http.NewServeMux()
Expand Down Expand Up @@ -365,6 +398,43 @@ func BuildOpenAPICommand(rt *runtime.Runtime) *cli.Command {
}
}

func newConfiguredTokenCache(
rt *runtime.Runtime,
c *cli.Command,
connections ...grpc.ClientConnInterface,
) (*tokenCache, error) {
return newTokenCacheWithValidation(
rt,
c.String("stateless-token-regex"),
c.Int("stateless-token-min-length"),
c.Int("stateless-token-max-length"),
connections...,
)
}

func tokenValidationFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "stateless-token-regex",
Value: defaultS3MJWTRegex,
Sources: cli.EnvVars("S3M_STATELESS_TOKEN_REGEX"),
Usage: "Regular expression required for tokens before stateless introspection",
},
&cli.IntFlag{
Name: "stateless-token-min-length",
Value: defaultS3MTokenMinLength,
Sources: cli.EnvVars("S3M_STATELESS_TOKEN_MIN_LENGTH"),
Usage: "Minimum token length allowed for stateless introspection",
},
&cli.IntFlag{
Name: "stateless-token-max-length",
Value: defaultS3MTokenMaxLength,
Sources: cli.EnvVars("S3M_STATELESS_TOKEN_MAX_LENGTH"),
Usage: "Maximum token length allowed for stateless introspection",
},
}
}

func configureGitDocs(ctx context.Context, rt *runtime.Runtime, c *cli.Command) error {
docsURL := c.String("docs-url")
if docsURL == "" {
Expand Down
Loading
Loading