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
10 changes: 10 additions & 0 deletions mcp/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ func compareLevels(l1, l2 LoggingLevel) int {
return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2))
}

type logLevelContextKey struct{}

func logLevelFromContext(ctx context.Context) (LoggingLevel, bool) {
v, ok := ctx.Value(logLevelContextKey{}).(LoggingLevel)
return v, ok
}

// LoggingHandlerOptions are options for a LoggingHandler.
//
// Deprecated: the logging feature is deprecated as of protocol version
Expand Down Expand Up @@ -146,6 +153,9 @@ func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingH
func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool {
// This is also checked in ServerSession.LoggingMessage, so checking it here
// is just an optimization that skips building the JSON.
if mcpLevel, ok := logLevelFromContext(ctx); ok {
return mcpLevel != "" && level >= mcpLevelToSlog(mcpLevel)
}
h.ss.mu.Lock()
mcpLevel := h.ss.state.LogLevel
h.ss.mu.Unlock()
Expand Down
76 changes: 76 additions & 0 deletions mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3439,3 +3439,79 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) {
t.Fatalf("CallCustomMethod with typed-nil params: %v", err)
}
}

func TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests(t *testing.T) {
ctx := context.Background()
s := NewServer(testImpl, nil)
_, st := NewInMemoryTransports()
ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ss.Close() })

logged := make(chan LoggingLevel, 1)
s.AddSendingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == notificationLoggingMessage {
logged <- req.GetParams().(*LoggingMessageParams).Level
return nil, nil
}
return next(ctx, method, req)
}
})

started := make(chan struct{})
release := make(chan struct{})
AddTool(s, &Tool{Name: "blocked-log"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) {
close(started)
<-release
if err := req.Session.Log(ctx, &LoggingMessageParams{Level: "warning", Data: "request log"}); err != nil {
return nil, nil, err
}
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
})
AddTool(s, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
})

withLogLevel := &CallToolParams{Name: "blocked-log"}
withLogLevel.SetMeta(newProtocolMeta("warning"))
errc := make(chan error, 1)
go func() {
_, err := ss.handle(ctx, req(1, methodCallTool, withLogLevel))
errc <- err
}()

<-started
withoutLogLevel := &CallToolParams{Name: "noop"}
withoutLogLevel.SetMeta(newProtocolMeta(""))
if _, err := ss.handle(ctx, req(2, methodCallTool, withoutLogLevel)); err != nil {
t.Fatal(err)
}
close(release)
if err := <-errc; err != nil {
t.Fatal(err)
}

select {
case got := <-logged:
if got != "warning" {
t.Fatalf("logged level = %q, want warning", got)
}
default:
t.Fatal("request-scoped warning log was suppressed after another request cleared session log level")
}
}

func newProtocolMeta(logLevel LoggingLevel) Meta {
m := Meta{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: testImpl,
MetaKeyClientCapabilities: (&ClientCapabilities{}).toV2(),
}
if logLevel != "" {
m[MetaKeyLogLevel] = logLevel
}
return m
}
12 changes: 7 additions & 5 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1736,9 +1736,12 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli
// (at least twelve months). See
// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging.
func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error {
ss.mu.Lock()
logLevel := ss.state.LogLevel
ss.mu.Unlock()
logLevel, ok := logLevelFromContext(ctx)
if !ok {
ss.mu.Lock()
logLevel = ss.state.LogLevel
ss.mu.Unlock()
}
if logLevel == "" {
// The spec is unclear, but seems to imply that no log messages are sent until the client
// sets the level.
Expand Down Expand Up @@ -1926,9 +1929,8 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
// server->client calls and notifications to the incoming request from which
// they originated. See [idContextKey] for details.
ctx = context.WithValue(ctx, idContextKey{}, req.ID)
// For new-protocol requests, propagate the per-request log level.
if validatedMeta.usesNewProtocol {
ss.setLevel(ctx, &SetLoggingLevelParams{Level: validatedMeta.logLevel})
ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel)
}
res, err := handleReceive(ctx, ss, req)
if err != nil {
Expand Down