mcp: closing a session right after a cancelled call drops notifications/cancelled, so the server handler keeps running

Abierto
#1,505 1 comentario 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

@vshalpnjabi ya está trabajando en esto.

Desde el 6/11/2026.

  • #1 de @vshalpnjabi — cerrado sin fusionar
  • #1508 de @vshalpnjabi — cerrado sin fusionar

Evaluación

Dificultad
4/5
Tiempo estimado
3-5 días
Aptitud para principiantes
48/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
go

Línea de trabajo

Start by tracing call() and ClientSession.Close through the JSON-RPC connection shutdown path, then inspect how outgoing notifications are tracked and drained. The issue’s repro can confirm the failure and provide a starting test. Done means closing immediately after a cancelled call still delivers notifications/cancelled, cancels the server handler, and avoids waiting for the 5-second timeout.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

Summary

Since v1.8.0 (#1151), call() retires a cancelled call and sends notifications/cancelled from a goroutine. If the client closes the session right after the call returns (a graceful shutdown that cancels in-flight tool calls and then closes, for example), the notification is dropped. On a stateful streamable HTTP server the tool handler is then never cancelled, and ClientSession.Close blocks for closeDeleteTimeout (5s).

MCPGODEBUG=blockingcancelnotify=1 restores the v1.7.0 ordering, but it is scheduled for removal in v1.9.0.

Repro (v1.8.0, also matches main)
package repro

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func TestCloseAfterCanceledCallCancelsTheServerHandler(t *testing.T) {
	entered := make(chan struct{}, 1)
	canceled := make(chan struct{}, 1)
	release := make(chan struct{})

	server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "1"}, nil)
	server.AddTool(&mcp.Tool{Name: "wait", InputSchema: map[string]any{"type": "object"}},
		func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			entered <- struct{}{}
			select {
			case <-ctx.Done():
				canceled <- struct{}{}
				return nil, ctx.Err()
			case <-release:
				return &mcp.CallToolResult{}, nil
			}
		})
	httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil))
	t.Cleanup(httpServer.Close)
	t.Cleanup(func() { close(release) })

	client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "1"}, nil)
	session, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(t.Context())
	go func() {
		<-entered
		cancel()
	}()
	if _, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "wait"}); !errors.Is(err, context.Canceled) {
		t.Fatalf("CallTool: got %v, want context.Canceled", err)
	}

	start := time.Now()
	_ = session.Close()
	closed := time.Since(start)

	select {
	case <-canceled:
		t.Logf("handler canceled; Close took %v", closed.Round(time.Millisecond))
	case <-time.After(2 * time.Second):
		t.Fatalf("server handler never saw the cancellation; Close took %v", closed.Round(time.Millisecond))
	}
}

Go 1.26.10, linux/amd64:

  • Default: fails 20/20, server handler never saw the cancellation; Close took 5s.
  • MCPGODEBUG=blockingcancelnotify=1: passes 20/20, handler canceled; Close took 1ms.
Why
  1. call() runs conn.Retire(call, ...), starts the notify goroutine and returns.
  2. ClientSession.Close → jsonrpc2.Connection.Close marks the connection closing.
  3. The goroutine's Notify finds no outgoing calls and no incoming requests, so shuttingDown(ErrClientClosing) rejects it. The notification is never written.
  4. The streamable client sends DELETE. serveStatefulDELETE → ServerSession.Close cancels only subscriptions/listen handlers, then conn.Close waits for in-flight requests, including the tool handler that is still blocked on its context.
  5. The DELETE hangs until the client gives up after closeDeleteTimeout and cancels its connection. The handler keeps running.

So closing the connection does not signal the cancellation to a stateful streamable server, which is the assumption in #1241.

Suggestion

Keep the caller's return immediate (the point of #1150), but register the pending notification synchronously before call() returns, e.g. bump outgoingNotifications (or an equivalent "pending cancel" counter) before spawning the goroutine. Then Close allows the write and its drain waits for it, bounded by notifyCancellationTimeout.

We hit this in a graceful-shutdown path that cancels tool calls, waits for them to return, then closes the session; we're running with blockingcancelnotify=1 until there is a fix.

Lenguaje dominante
Go
Estrellas
5.3k
Forks
607
Merge medio
1 d 11 h
PR fusionados (30 d)
82

Preparar el entorno

Abrir en Codespaces

Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de modelcontextprotocol/go-sdk

Todos los issues de modelcontextprotocol/go-sdk

Issues similares

Más issues de Go

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.