Repository: modelcontextprotocol/go-sdk

Issue:
Keepalive support
Add support for automatic keepalive 'ping' requests.

Design: https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md#ping--keepalive


Discussion:
@rwjblue-glean: 👋 sorry for the late ping, I realize that I was supposed to confirm that it was ok for new contributors first (I didn't notice that part in the CONTRIBUTING.md at first 🤦 ). 

I sent #39 to implement this.@findleyr: No worries, and thanks for reading CONTRIBUTING.md!

No harm done: we hadn't started working on this and your contribution is very welcome!

Solution patch:
diff --git a/.github/workflows/readme-check.yml b/.github/workflows/readme-check.yml
new file mode 100644
index 0000000..b3398ea
--- /dev/null
+++ b/.github/workflows/readme-check.yml
@@ -0,0 +1,38 @@
+name: README Check
+on:
+  workflow_dispatch:  
+  pull_request:
+    paths:
+      - 'internal/readme/**'
+      - 'README.md'
+  
+permissions:
+  contents: read
+
+jobs:
+  readme-check:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Set up Go
+      uses: actions/setup-go@v5
+    - name: Check out code
+      uses: actions/checkout@v4
+    - name: Check README is up-to-date
+      run: |
+        cd internal/readme
+        make
+        if [ -n "$(git status --porcelain)" ]; then
+          echo "ERROR: README.md is not up-to-date!"
+          echo ""
+          echo "The README.md file differs from what would be generated by running 'make' in internal/readme/."
+          echo "Please update internal/readme/README.src.md instead of README.md directly,"
+          echo "then run 'make' in the internal/readme/ directory to regenerate README.md."
+          echo ""
+          echo "Changes:"
+          git status --porcelain
+          echo ""
+          echo "Diff:"
+          git diff
+          exit 1
+        fi
+        echo "README.md is up-to-date"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0c876da..4073973 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -68,6 +68,15 @@ This process is similar to the [Go proposal
 process](https://github.com/golang/proposal), but is necessarily lighter weight
 to accommodate the greater rate of change expected for the SDK.
 
+### Design discussion
+
+For open ended design discussion (anything that doesn't fall into the issue
+categories above), use [GitHub
+Discussions](https://github.com/modelcontextprotocol/go-sdk/discussions).
+Ideally, each discussion should be focused on one aspect of the design. For
+example: Tool Binding and Session APIs would be two separate discussions.
+When discussions reach a consensus, they should be promoted into proposals.
+
 ## Contributing code
 
 The project uses GitHub pull requests (PRs) to review changes.
@@ -96,6 +105,19 @@ copyright header following the format below:
 // license that can be found in the LICENSE file.
 ```
 
+### Updating the README
+
+The top-level `README.md` file is generated from `internal/readme/README.src.md`
+and should not be edited directly. To update the README:
+
+1. Make your changes to `internal/readme/README.src.md`
+2. Run `make` in the `internal/readme/` directory to regenerate `README.md`
+3. Commit both files together
+
+The CI system will automatically check that the README is up-to-date by running
+`make` and verifying no changes result. If you see a CI failure about the
+README being out of sync, follow the steps above to regenerate it.
+
 ## Code of conduct
 
 This project follows the [Go Community Code of Conduct](https://go.dev/conduct).
diff --git a/README.md b/README.md
index e60947c..17181ea 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,7 @@
 <!-- Autogenerated by weave; DO NOT EDIT -->
 # MCP Go SDK
 
-<!-- TODO: update pkgsite links here to point to the modelcontextprotocol
-module, once it exists. -->
-
-[![PkgGoDev](https://pkg.go.dev/badge/golang.org/x/tools)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk)
+[![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk)
 
 This repository contains an unreleased implementation of the official Go
 software development kit (SDK) for the Model Context Protocol (MCP).
@@ -15,6 +12,18 @@ proposals, but don't use it in real projects. See the issue tracker for known
 issues and missing features. We aim to release a stable version of the SDK in
 August, 2025.
 
+## Design
+
+The design doc for this SDK is at [design.md](./design/design.md), which was
+initially reviewed at
+[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364).
+
+Further design discussion should occur in
+[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete
+proposals) or
+[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for
+open-ended discussion. See CONTRIBUTING.md for details.
+
 ## Package documentation
 
 The SDK consists of two importable packages:
@@ -116,15 +125,6 @@ func main() {
 
 The `examples/` directory contains more example clients and servers.
 
-## Design
-
-The design doc for this SDK is at [design.md](./design/design.md), which was
-initially reviewed at
-[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364).
-
-Further design discussion should occur in GitHub issues. See CONTRIBUTING.md
-for details.
-
 ## Acknowledgements
 
 Several existing Go MCP SDKs inspired the development and design of this
diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md
index 9938366..629629a 100644
--- a/internal/readme/README.src.md
+++ b/internal/readme/README.src.md
@@ -1,9 +1,6 @@
 # MCP Go SDK
 
-<!-- TODO: update pkgsite links here to point to the modelcontextprotocol
-module, once it exists. -->
-
-[![PkgGoDev](https://pkg.go.dev/badge/golang.org/x/tools)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk)
+[![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk)
 
 This repository contains an unreleased implementation of the official Go
 software development kit (SDK) for the Model Context Protocol (MCP).
@@ -14,6 +11,18 @@ proposals, but don't use it in real projects. See the issue tracker for known
 issues and missing features. We aim to release a stable version of the SDK in
 August, 2025.
 
+## Design
+
+The design doc for this SDK is at [design.md](./design/design.md), which was
+initially reviewed at
+[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364).
+
+Further design discussion should occur in
+[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete
+proposals) or
+[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for
+open-ended discussion. See CONTRIBUTING.md for details.
+
 ## Package documentation
 
 The SDK consists of two importable packages:
@@ -41,15 +50,6 @@ with its client over stdin/stdout:
 
 The `examples/` directory contains more example clients and servers.
 
-## Design
-
-The design doc for this SDK is at [design.md](./design/design.md), which was
-initially reviewed at
-[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364).
-
-Further design discussion should occur in GitHub issues. See CONTRIBUTING.md
-for details.
-
 ## Acknowledgements
 
 Several existing Go MCP SDKs inspired the development and design of this
diff --git a/mcp/client.go b/mcp/client.go
index 17875c3..82fbb01 100644
--- a/mcp/client.go
+++ b/mcp/client.go
@@ -9,6 +9,7 @@ import (
 	"iter"
 	"slices"
 	"sync"
+	"time"
 
 	"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
 )
@@ -56,6 +57,10 @@ type ClientOptions struct {
 	ResourceListChangedHandler  func(context.Context, *ClientSession, *ResourceListChangedParams)
 	LoggingMessageHandler       func(context.Context, *ClientSession, *LoggingMessageParams)
 	ProgressNotificationHandler func(context.Context, *ClientSession, *ProgressNotificationParams)
+	// If non-zero, defines an interval for regular "ping" requests.
+	// If the peer fails to respond to pings originating from the keepalive check,
+	// the session is automatically closed.
+	KeepAlive time.Duration
 }
 
 // bind implements the binder[*ClientSession] interface, so that Clients can
@@ -118,6 +123,11 @@ func (c *Client) Connect(ctx context.Context, t Transport) (cs *ClientSession, e
 		_ = cs.Close()
 		return nil, err
 	}
+
+	if c.opts.KeepAlive > 0 {
+		cs.startKeepalive(c.opts.KeepAlive)
+	}
+
 	return cs, nil
 }
 
@@ -131,12 +141,21 @@ type ClientSession struct {
 	conn             *jsonrpc2.Connection
 	client           *Client
 	initializeResult *InitializeResult
+	keepaliveCancel  context.CancelFunc
 }
 
 // Close performs a graceful close of the connection, preventing new requests
 // from being handled, and waiting for ongoing requests to return. Close then
 // terminates the connection.
 func (cs *ClientSession) Close() error {
+	// Note: keepaliveCancel access is safe without a mutex because:
+	// 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls)
+	// 2. context.CancelFunc is safe to call multiple times and from multiple goroutines
+	// 3. The keepalive goroutine calls Close on ping failure, but this is safe since
+	//    Close is idempotent and conn.Close() handles concurrent calls correctly
+	if cs.keepaliveCancel != nil {
+		cs.keepaliveCancel()
+	}
 	return cs.conn.Close()
 }
 
@@ -146,6 +165,11 @@ func (cs *ClientSession) Wait() error {
 	return cs.conn.Wait()
 }
 
+// startKeepalive starts the keepalive mechanism for this client session.
+func (cs *ClientSession) startKeepalive(interval time.Duration) {
+	startKeepalive(cs, interval, &cs.keepaliveCancel)
+}
+
 // AddRoots adds the given roots to the client,
 // replacing any with the same URIs,
 // and notifies any connected servers.
diff --git a/mcp/server.go b/mcp/server.go
index 53088bd..44ec7aa 100644
--- a/mcp/server.go
+++ b/mcp/server.go
@@ -16,6 +16,7 @@ import (
 	"path/filepath"
 	"slices"
 	"sync"
+	"time"
 
 	"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
 	"github.com/modelcontextprotocol/go-sdk/internal/util"
@@ -57,6 +58,10 @@ type ServerOptions struct {
 	RootsListChangedHandler func(context.Context, *ServerSession, *RootsListChangedParams)
 	// If non-nil, called when "notifications/progress" is received.
 	ProgressNotificationHandler func(context.Context, *ServerSession, *ProgressNotificationParams)
+	// If non-zero, defines an interval for regular "ping" requests.
+	// If the peer fails to respond to pings originating from the keepalive check,
+	// the session is automatically closed.
+	KeepAlive time.Duration
 }
 
 // NewServer creates a new MCP server. The resulting server has no features:
@@ -460,6 +465,9 @@ func (s *Server) Connect(ctx context.Context, t Transport) (*ServerSession, erro
 }
 
 func (s *Server) callInitializedHandler(ctx context.Context, ss *ServerSession, params *InitializedParams) (Result, error) {
+	if s.opts.KeepAlive > 0 {
+		ss.startKeepalive(s.opts.KeepAlive)
+	}
 	return callNotificationHandler(ctx, s.opts.InitializedHandler, ss, params)
 }
 
@@ -492,6 +500,7 @@ type ServerSession struct {
 	logLevel         LoggingLevel
 	initializeParams *InitializeParams
 	initialized      bool
+	keepaliveCancel  context.CancelFunc
 }
 
 // Ping pings the client.
@@ -678,6 +687,14 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLevelParams) (*e
 // requests from being handled, and waiting for ongoing requests to return.
 // Close then terminates the connection.
 func (ss *ServerSession) Close() error {
+	if ss.keepaliveCancel != nil {
+		// Note: keepaliveCancel access is safe without a mutex because:
+		// 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls)
+		// 2. context.CancelFunc is safe to call multiple times and from multiple goroutines
+		// 3. The keepalive goroutine calls Close on ping failure, but this is safe since
+		//    Close is idempotent and conn.Close() handles concurrent calls correctly
+		ss.keepaliveCancel()
+	}
 	return ss.conn.Close()
 }
 
@@ -686,6 +703,11 @@ func (ss *ServerSession) Wait() error {
 	return ss.conn.Wait()
 }
 
+// startKeepalive starts the keepalive mechanism for this server session.
+func (ss *ServerSession) startKeepalive(interval time.Duration) {
+	startKeepalive(ss, interval, &ss.keepaliveCancel)
+}
+
 // pageToken is the internal structure for the opaque pagination cursor.
 // It will be Gob-encoded and then Base64-encoded for use as a string token.
 type pageToken struct {
diff --git a/mcp/shared.go b/mcp/shared.go
index 0edc068..a2d5147 100644
--- a/mcp/shared.go
+++ b/mcp/shared.go
@@ -310,3 +310,41 @@ type listResult[T any] interface {
 	// Returns a pointer to the param's NextCursor field.
 	nextCursorPtr() *string
 }
+
+// keepaliveSession represents a session that supports keepalive functionality.
+type keepaliveSession interface {
+	Ping(ctx context.Context, params *PingParams) error
+	Close() error
+}
+
+// startKeepalive starts the keepalive mechanism for a session.
+// It assigns the cancel function to the provided cancelPtr and starts a goroutine
+// that sends ping messages at the specified interval.
+func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) {
+	ctx, cancel := context.WithCancel(context.Background())
+	// Assign cancel function before starting goroutine to avoid race condition.
+	// We cannot return it because the caller may need to cancel during the
+	// window between goroutine scheduling and function return.
+	*cancelPtr = cancel
+
+	go func() {
+		ticker := time.NewTicker(interval)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-ticker.C:
+				pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
+				err := session.Ping(pingCtx, nil)
+				pingCancel()
+				if err != nil {
+					// Ping failed, close the session
+					_ = session.Close()
+					return
+				}
+			}
+		}
+	}()
+}



Test patch:
diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go
index 0aaa47e..5f42b1b 100644
--- a/mcp/mcp_test.go
+++ b/mcp/mcp_test.go
@@ -838,3 +838,101 @@ func falseSchema() *jsonschema.Schema { return &jsonschema.Schema{Not: &jsonsche
 func nopHandler(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error) {
 	return nil, nil
 }
+
+func TestKeepAlive(t *testing.T) {
+	// TODO: try to use the new synctest package for this test once we upgrade to Go 1.24+.
+	// synctest would allow us to control time and avoid the time.Sleep calls, making the test
+	// faster and more deterministic.
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	ct, st := NewInMemoryTransports()
+
+	serverOpts := &ServerOptions{
+		KeepAlive: 100 * time.Millisecond,
+	}
+	s := NewServer("testServer", "v1.0.0", serverOpts)
+	s.AddTools(NewServerTool("greet", "say hi", sayHi))
+
+	ss, err := s.Connect(ctx, st)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ss.Close()
+
+	clientOpts := &ClientOptions{
+		KeepAlive: 100 * time.Millisecond,
+	}
+	c := NewClient("testClient", "v1.0.0", clientOpts)
+	cs, err := c.Connect(ctx, ct)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer cs.Close()
+
+	// Wait for a few keepalive cycles to ensure pings are working
+	time.Sleep(300 * time.Millisecond)
+
+	// Test that the connection is still alive by making a call
+	result, err := cs.CallTool(ctx, &CallToolParams{
+		Name:      "greet",
+		Arguments: map[string]any{"Name": "user"},
+	})
+	if err != nil {
+		t.Fatalf("call failed after keepalive: %v", err)
+	}
+	if len(result.Content) == 0 {
+		t.Fatal("expected content in result")
+	}
+	if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" {
+		t.Fatalf("unexpected result: %v", result.Content[0])
+	}
+}
+
+func TestKeepAliveFailure(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	ct, st := NewInMemoryTransports()
+
+	// Server without keepalive (to test one-sided keepalive)
+	s := NewServer("testServer", "v1.0.0", nil)
+	s.AddTools(NewServerTool("greet", "say hi", sayHi))
+	ss, err := s.Connect(ctx, st)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Client with short keepalive
+	clientOpts := &ClientOptions{
+		KeepAlive: 50 * time.Millisecond,
+	}
+	c := NewClient("testClient", "v1.0.0", clientOpts)
+	cs, err := c.Connect(ctx, ct)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer cs.Close()
+
+	// Let the connection establish properly first
+	time.Sleep(30 * time.Millisecond)
+
+	// simulate ping failure
+	ss.Close()
+
+	// Wait for keepalive to detect the failure and close the client
+	// check periodically instead of just waiting
+	deadline := time.Now().Add(1 * time.Second)
+	for time.Now().Before(deadline) {
+		_, err = cs.CallTool(ctx, &CallToolParams{
+			Name:      "greet",
+			Arguments: map[string]any{"Name": "user"},
+		})
+		if errors.Is(err, ErrConnectionClosed) {
+			return // Test passed
+		}
+		time.Sleep(25 * time.Millisecond)
+	}
+
+	t.Errorf("expected connection to be closed by keepalive, but it wasn't. Last error: %v", err)
+}