diff --git a/mcp/logging.go b/mcp/logging.go index c4dd1b550..cff40da2a 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "log/slog" + "maps" "slices" "sync" "time" @@ -48,11 +49,24 @@ func init() { } } +// sortedSlogLevels holds the keys of slogToMCP in ascending order, for +// mapping slog levels that fall between two MCP levels. +var sortedSlogLevels = slices.Sorted(maps.Keys(slogToMCP)) + +// slogLevelToMCP returns the MCP logging level for sl. A level that is not one +// of the eight MCP levels maps to the highest MCP level that does not exceed +// it, so that a custom level (such as slog.LevelInfo+1) is filtered the same +// way by [LoggingHandler.Enabled] and by [ServerSession.Log], which compare +// slog levels. Levels below LevelDebug map to "debug". func slogLevelToMCP(sl slog.Level) LoggingLevel { if ml, ok := slogToMCP[sl]; ok { return ml } - return "debug" // for lack of a better idea + i, _ := slices.BinarySearch(sortedSlogLevels, sl) + if i == 0 { + return "debug" + } + return slogToMCP[sortedSlogLevels[i-1]] } func mcpLevelToSlog(ll LoggingLevel) slog.Level { diff --git a/mcp/logging_test.go b/mcp/logging_test.go new file mode 100644 index 000000000..8663899d5 --- /dev/null +++ b/mcp/logging_test.go @@ -0,0 +1,67 @@ +package mcp + +import ( + "context" + "log/slog" + "testing" + "time" +) + +func TestSlogLevelToMCPCustomLevels(t *testing.T) { + for _, tt := range []struct { + level slog.Level + want LoggingLevel + }{ + {slog.LevelDebug - 4, "debug"}, + {slog.LevelDebug, "debug"}, + {slog.LevelInfo - 1, "debug"}, + {slog.LevelInfo, "info"}, + {slog.LevelInfo + 1, "info"}, + {LevelNotice, "notice"}, + {slog.LevelWarn + 1, "warning"}, + {slog.LevelError + 2, "error"}, + {LevelEmergency + 10, "emergency"}, + } { + if got := slogLevelToMCP(tt.level); got != tt.want { + t.Errorf("slogLevelToMCP(%v) = %q, want %q", tt.level, got, tt.want) + } + } +} + +// A message logged at a custom slog level that LoggingHandler.Enabled +// accepts must reach the client, with the nearest MCP level. +func TestLoggingHandlerCustomLevel(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + got := make(chan *LoggingMessageParams, 10) + c := NewClient(testImpl, &ClientOptions{ + LoggingMessageHandler: func(_ context.Context, r *LoggingMessageRequest) { got <- r.Params }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + if err := cs.SetLoggingLevel(ctx, &SetLoggingLevelParams{Level: "info"}); err != nil { + t.Fatal(err) + } + logger := slog.New(NewLoggingHandler(ss, nil)) + lvl := slog.LevelInfo + 1 + if !logger.Enabled(ctx, lvl) { + t.Fatalf("Enabled(%v) = false, want true", lvl) + } + logger.Log(ctx, lvl, "hello") + select { + case p := <-got: + if p.Level != "info" { + t.Errorf("got level %q, want %q", p.Level, "info") + } + case <-time.After(2 * time.Second): + t.Fatalf("message at level %v was dropped", lvl) + } +}