Repository: modelcontextprotocol/go-sdk

Issue:
Proposal: add TokenInfo.UserID
Add a UserID field of type string to auth.TokenInfo. A [TokenVerifier](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v1.0.0/auth#TokenVerifier) may set this field to a non-empty value if it knows how to find the user ID in the token.

If present, the user ID can be used to increase security. See, for example, #586. Instead of calling GetUserID as suggested in that proposal, code would call TokenInfoFromContext and then access the UserID field.

/cc @findleyr 

Hints from issue comments:
@findleyr: Thanks, this sounds like a better alternative to #586.@younaman: I agree

Solution patch:
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 748ac91..9a90793 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -6,6 +6,9 @@ on:
 
   workflow_dispatch:
 
+permissions:
+  contents: read
+
 jobs:
   vulncheck:
     runs-on: ubuntu-latest
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 407b3bf..b340182 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,6 +26,34 @@ containing the SDK, run:
 go work init ./project ./go-sdk
 ```
 
+### Conformance tests
+
+The SDK includes a script to run the official MCP conformance tests against the
+SDK's conformance server:
+
+```sh
+./scripts/conformance.sh
+```
+
+By default, results are cleaned up after the script runs. To save results to a
+specific directory:
+
+```sh
+./scripts/conformance.sh --result_dir ./conformance-results
+```
+
+To run against a local checkout of the
+[conformance repo](https://github.com/modelcontextprotocol/conformance) instead
+of the latest npm release:
+
+```sh
+./scripts/conformance.sh --conformance_repo ~/src/conformance
+```
+
+Note: you must run `npm install` in the conformance repo first.
+
+Run `./scripts/conformance.sh --help` for more options.
+
 ## Filing issues
 
 This project uses the [GitHub issue
diff --git a/auth/auth.go b/auth/auth.go
index 0eea1d8..6c34571 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -17,6 +17,11 @@ import (
 type TokenInfo struct {
 	Scopes     []string
 	Expiration time.Time
+	// UserID is an optional identifier for the authenticated user.
+	// If set by a TokenVerifier, it can be used by transports to prevent
+	// session hijacking by ensuring that all requests for a given session
+	// come from the same user.
+	UserID string
 	// TODO: add standard JWT fields
 	Extra map[string]any
 }
diff --git a/mcp/streamable.go b/mcp/streamable.go
index 9e210ab..9ab3303 100644
--- a/mcp/streamable.go
+++ b/mcp/streamable.go
@@ -51,6 +51,10 @@ type StreamableHTTPHandler struct {
 type sessionInfo struct {
 	session   *ServerSession
 	transport *StreamableServerTransport
+	// userID is the user ID from the TokenInfo when the session was created.
+	// If non-empty, subsequent requests must have the same user ID to prevent
+	// session hijacking.
+	userID string
 
 	// If timeout is set, automatically close the session after an idle period.
 	timeout time.Duration
@@ -238,6 +242,15 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
 			http.Error(w, "session not found", http.StatusNotFound)
 			return
 		}
+		// Prevent session hijacking: if the session was created with a user ID,
+		// verify that subsequent requests come from the same user.
+		if sessInfo != nil && sessInfo.userID != "" {
+			tokenInfo := auth.TokenInfoFromContext(req.Context())
+			if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID {
+				http.Error(w, "session user mismatch", http.StatusForbidden)
+				return
+			}
+		}
 	}
 
 	if req.Method == http.MethodDelete {
@@ -404,9 +417,16 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
 			http.Error(w, "failed connection", http.StatusInternalServerError)
 			return
 		}
+		// Capture the user ID from the token info to enable session hijacking
+		// prevention on subsequent requests.
+		var userID string
+		if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil {
+			userID = tokenInfo.UserID
+		}
 		sessInfo = &sessionInfo{
 			session:   session,
 			transport: transport,
+			userID:    userID,
 		}
 
 		if stateless {
diff --git a/scripts/conformance.sh b/scripts/conformance.sh
new file mode 100755
index 0000000..9e7d300
--- /dev/null
+++ b/scripts/conformance.sh
@@ -0,0 +1,111 @@
+#!/bin/bash
+# Copyright 2025 The Go MCP SDK Authors. All rights reserved.
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file.
+
+# Run MCP conformance tests against the Go SDK conformance server.
+
+set -e
+
+PORT="${PORT:-3000}"
+SERVER_PID=""
+RESULT_DIR=""
+WORKDIR=""
+CONFORMANCE_REPO=""
+
+usage() {
+    echo "Usage: $0 [options]"
+    echo ""
+    echo "Run MCP conformance tests against the Go SDK conformance server."
+    echo ""
+    echo "Options:"
+    echo "  --result_dir <dir>       Save results to the specified directory"
+    echo "  --conformance_repo <dir> Run conformance tests from a local checkout"
+    echo "                           instead of using the latest npm release"
+    echo "  --help                   Show this help message"
+}
+
+# Parse arguments.
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --result_dir)
+            RESULT_DIR="$2"
+            shift 2
+            ;;
+        --conformance_repo)
+            CONFORMANCE_REPO="$2"
+            shift 2
+            ;;
+        --help)
+            usage
+            exit 0
+            ;;
+        *)
+            echo "Unknown option: $1"
+            usage
+            exit 1
+            ;;
+    esac
+done
+
+cleanup() {
+    if [ -n "$SERVER_PID" ]; then
+        kill "$SERVER_PID" 2>/dev/null || true
+    fi
+    # Clean up the work directory unless --result_dir was specified.
+    if [ -z "$RESULT_DIR" ] && [ -n "$WORKDIR" ]; then
+        rm -rf "$WORKDIR"
+    fi
+}
+trap cleanup EXIT
+
+# Set up the work directory.
+if [ -n "$RESULT_DIR" ]; then
+    mkdir -p "$RESULT_DIR"
+    WORKDIR="$RESULT_DIR"
+else
+    WORKDIR=$(mktemp -d)
+fi
+
+# Build the conformance server.
+go build -o "$WORKDIR/conformance-server" ./examples/server/conformance
+
+# Start the server in the background
+echo "Starting conformance server on port $PORT..."
+"$WORKDIR/conformance-server" -http=":$PORT" &
+SERVER_PID=$!
+
+echo "Server pid is $SERVER_PID"
+
+# Wait for server to be ready
+echo "Waiting for server to be ready..."
+for i in {1..30}; do
+    if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
+        echo "Server is ready."
+        break
+    fi
+    if [ "$i" -eq 30 ]; then
+        echo "Server failed to start within 15 seconds."
+        exit 1
+    fi
+    sleep 0.5
+done
+
+# Run conformance tests from the work directory to avoid writing results to the repo.
+echo "Running conformance tests..."
+if [ -n "$CONFORMANCE_REPO" ]; then
+    # Run from local checkout using npm run start.
+    (cd "$WORKDIR" && \
+        npm --prefix "$CONFORMANCE_REPO" run start -- \
+            server --url "http://localhost:$PORT")
+else
+    (cd "$WORKDIR" && \
+        npx @modelcontextprotocol/conformance@latest server --url "http://localhost:$PORT")
+fi
+
+echo ""
+if [ -n "$RESULT_DIR" ]; then
+    echo "See $RESULT_DIR for details."
+else
+    echo "Run with --result_dir to save results."
+fi



Test patch:
diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go
index d2234b8..9a90d24 100644
--- a/mcp/streamable_test.go
+++ b/mcp/streamable_test.go
@@ -1649,11 +1649,87 @@ func TestTokenInfo(t *testing.T) {
 	if !ok {
 		t.Fatal("not TextContent")
 	}
-	if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w {
+	if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC  map[]}"; g != w {
 		t.Errorf("got %q, want %q", g, w)
 	}
 }
 
+func TestSessionHijackingPrevention(t *testing.T) {
+	// This test verifies that sessions bound to a user ID cannot be accessed
+	// by a different user (session hijacking prevention).
+	ctx := context.Background()
+
+	server := NewServer(testImpl, nil)
+	streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil)
+
+	// Use the bearer token directly as the user ID. This simulates how a real
+	// verifier might extract a user ID from a JWT "sub" claim or introspection.
+	verifier := func(_ context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) {
+		return &auth.TokenInfo{
+			Scopes:     []string{"scope"},
+			UserID:     token,
+			Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC),
+		}, nil
+	}
+	handler := auth.RequireBearerToken(verifier, nil)(streamHandler)
+	httpServer := httptest.NewServer(mustNotPanic(t, handler))
+	defer httpServer.Close()
+
+	// Helper to send a JSON-RPC request as a given user.
+	doRequest := func(msg jsonrpc.Message, sessionID, userID string) *http.Response {
+		t.Helper()
+		data, _ := jsonrpc2.EncodeMessage(msg)
+		req, _ := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, bytes.NewReader(data))
+		req.Header.Set("Content-Type", "application/json")
+		req.Header.Set("Accept", "application/json, text/event-stream")
+		req.Header.Set("Authorization", "Bearer "+userID)
+		if sessionID != "" {
+			req.Header.Set("Mcp-Session-Id", sessionID)
+		}
+		resp, err := http.DefaultClient.Do(req)
+		if err != nil {
+			t.Fatalf("request failed: %v", err)
+		}
+		return resp
+	}
+
+	// Create a session as user1.
+	initReq := &jsonrpc.Request{Method: "initialize", ID: jsonrpc2.Int64ID(1)}
+	initReq.Params, _ = json.Marshal(&InitializeParams{
+		ProtocolVersion: protocolVersion20250618,
+		ClientInfo:      &Implementation{Name: "test", Version: "1.0"},
+	})
+	resp := doRequest(initReq, "", "user1")
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		t.Fatalf("initialize failed with status %d: %s", resp.StatusCode, body)
+	}
+	sessionID := resp.Header.Get("Mcp-Session-Id")
+	if sessionID == "" {
+		t.Fatal("no session ID in response")
+	}
+
+	pingReq := &jsonrpc.Request{Method: "ping", ID: jsonrpc2.Int64ID(2)}
+	pingReq.Params, _ = json.Marshal(&PingParams{})
+
+	// Try to access the session as user2 - should fail.
+	resp2 := doRequest(pingReq, sessionID, "user2")
+	defer resp2.Body.Close()
+	if resp2.StatusCode != http.StatusForbidden {
+		body, _ := io.ReadAll(resp2.Body)
+		t.Errorf("expected status %d for user mismatch, got %d: %s", http.StatusForbidden, resp2.StatusCode, body)
+	}
+
+	// Access as original user1 should succeed.
+	resp3 := doRequest(pingReq, sessionID, "user1")
+	defer resp3.Body.Close()
+	if resp3.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp3.Body)
+		t.Errorf("expected status %d for matching user, got %d: %s", http.StatusOK, resp3.StatusCode, body)
+	}
+}
+
 func TestStreamableGET(t *testing.T) {
 	// This test checks the fix for problematic behavior described in #410:
 	// Hanging GET headers should be written immediately, even if there are no