From edda03157834e15815eef2712e363b38735548ee Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 12 May 2025 08:35:02 -0400 Subject: [PATCH] internal/mcp: implement logging Provide a ServerSession method that directly implements the spec method. Also provide a custom slog.Handler that writes a notification on each log call. It uses a slog.JSONHandler to format the message as JSON, then adds the additional fields of LoggingMessageParams. I decided that the direct method should handle levels, since that is in the spec, but that rate-limiting is an extra feature, so is only provided in the slog handler. Change-Id: I1190ca124e992e61fb633ac52ee3492c8e6d99c9 Reviewed-on: https://go-review.googlesource.com/c/tools/+/674498 Auto-Submit: Jonathan Amsterdam LUCI-TryBot-Result: Go LUCI Reviewed-by: Sam Thanawalla Reviewed-by: Robert Findley --- design/design.md | 29 +++++++- mcp/client.go | 13 ++++ mcp/generate.go | 29 ++++++-- mcp/logging.go | 189 +++++++++++++++++++++++++++++++++++++++++++++++ mcp/mcp_test.go | 84 +++++++++++++++++++++ mcp/protocol.go | 80 ++++++++++++-------- mcp/server.go | 42 +++++++++-- 7 files changed, 420 insertions(+), 46 deletions(-) create mode 100644 mcp/logging.go diff --git a/design/design.md b/design/design.md index 41ee445..3b2be85 100644 --- a/design/design.md +++ b/design/design.md @@ -823,6 +823,27 @@ Clients call the spec method `Complete` to request completions. Servers automati ### Logging +MCP specifies a notification for servers to log to clients. Server sessions implement this with the `LoggingMessage` method. It honors the minimum log level established by the client session's `SetLevel` call. + +As a convenience, we also provide a `slog.Handler` that allows server authors to write logs with the `log/slog` package:: +```go +// A LoggingHandler is a [slog.Handler] for MCP. +type LoggingHandler struct {...} + +// LoggingHandlerOptions are options for a LoggingHandler. +type LoggingHandlerOptions struct { + // The value for the "logger" field of logging notifications. + LoggerName string + // Limits the rate at which log messages are sent. + // If zero, there is no rate limiting. + MinInterval time.Duration +} + +// NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a +// [slog.JSONHandler]. +func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler +``` + Server-to-client logging is configured with `ServerOptions`: ```go @@ -832,24 +853,24 @@ type ServerOptions { LoggerName string // Log notifications to a single ClientSession will not be // sent more frequently than this duration. - LogInterval time.Duration + LoggingInterval time.Duration } ``` -Server sessions have a field `Logger` holding a `slog.Logger` that writes to the client session. A call to a log method like `Info` is translated to a `LoggingMessageNotification` as follows: +A call to a log method like `Info` is translated to a `LoggingMessageNotification` as follows: - The attributes and the message populate the "data" property with the output of a `slog.JSONHandler`: The result is always a JSON object, with the key "msg" for the message. - If the `LoggerName` server option is set, it populates the "logger" property. -- The standard slog levels `Info`, `Debug`, `Warn` and `Error` map to the corresponding levels in the MCP spec. The other spec levels map to integers between the slog levels. For example, "notice" is level 2 because it is between "warning" (slog value 4) and "info" (slog value 0). The `mcp` package defines consts for these levels. To log at the "notice" level, a handler would call `session.Logger.Log(ctx, mcp.LevelNotice, "message")`. +- The standard slog levels `Info`, `Debug`, `Warn` and `Error` map to the corresponding levels in the MCP spec. The other spec levels map to integers between the slog levels. For example, "notice" is level 2 because it is between "warning" (slog value 4) and "info" (slog value 0). The `mcp` package defines consts for these levels. To log at the "notice" level, a handler would call `Log(ctx, mcp.LevelNotice, "message")`. A client that wishes to receive log messages must provide a handler: ```go type ClientOptions struct { ... - LogMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams) + LoggingMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams) } ``` diff --git a/mcp/client.go b/mcp/client.go index f793b99..d9917b6 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -53,6 +53,7 @@ type ClientOptions struct { ToolListChangedHandler func(context.Context, *ClientSession, *ToolListChangedParams) PromptListChangedHandler func(context.Context, *ClientSession, *PromptListChangedParams) ResourceListChangedHandler func(context.Context, *ClientSession, *ResourceListChangedParams) + LoggingMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams) } // bind implements the binder[*ClientSession] interface, so that Clients can @@ -208,6 +209,7 @@ var clientMethodInfos = map[string]methodInfo[ClientSession]{ notificationToolListChanged: newMethodInfo(clientMethod((*Client).callToolChangedHandler)), notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler)), notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler)), + notificationLoggingMessage: newMethodInfo(clientMethod((*Client).callLoggingHandler)), } var _ session[ClientSession] = (*ClientSession)(nil) @@ -273,6 +275,10 @@ func (c *ClientSession) CallTool(ctx context.Context, name string, args map[stri return standardCall[CallToolResult](ctx, c.conn, methodCallTool, params) } +func (c *ClientSession) SetLevel(ctx context.Context, params *SetLevelParams) error { + return call(ctx, c.conn, methodSetLevel, params, nil) +} + // NOTE: the following struct should consist of all fields of callToolParams except name and arguments. // CallToolOptions contains options to [ClientSession.CallTool]. @@ -302,6 +308,13 @@ func (c *Client) callResourceChangedHandler(ctx context.Context, s *ClientSessio return callNotificationHandler(ctx, c.opts.ResourceListChangedHandler, s, params) } +func (c *Client) callLoggingHandler(ctx context.Context, cs *ClientSession, params *LoggingMessageParams) (any, error) { + if h := c.opts.LoggingMessageHandler; h != nil { + h(ctx, cs, params) + } + return nil, nil +} + func standardCall[TRes, TParams any](ctx context.Context, conn *jsonrpc2.Connection, method string, params TParams) (*TRes, error) { var result TRes if err := call(ctx, conn, method, params, &result); err != nil { diff --git a/mcp/generate.go b/mcp/generate.go index a2de04d..719520d 100644 --- a/mcp/generate.go +++ b/mcp/generate.go @@ -19,6 +19,7 @@ import ( "go/format" "io" "log" + "maps" "net/http" "os" "reflect" @@ -110,7 +111,18 @@ var declarations = config{ Name: "-", Fields: config{"Params": {Name: "ListToolsParams"}}, }, - "ListToolsResult": {}, + "ListToolsResult": {}, + "loggingCapabilities": {Substitute: "struct{}"}, + "LoggingLevel": {}, + "LoggingMessageNotification": { + Name: "-", + Fields: config{ + "Params": { + Name: "LoggingMessageParams", + Fields: config{"Data": {Substitute: "any"}}, + }, + }, + }, "ModelHint": {}, "ModelPreferences": {}, "PingRequest": { @@ -153,8 +165,13 @@ var declarations = config{ "Prompts": {Name: "promptCapabilities"}, "Resources": {Name: "resourceCapabilities"}, "Tools": {Name: "toolCapabilities"}, + "Logging": {Name: "loggingCapabilities"}, }, }, + "SetLevelRequest": { + Name: "-", + Fields: config{"Params": {Name: "SetLevelParams"}}, + }, "Tool": { Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}}, }, @@ -220,7 +237,7 @@ import ( } // Write out method names. fmt.Fprintln(buf, `const (`) - for name, s := range schema.Definitions { + for _, name := range slices.Sorted(maps.Keys(schema.Definitions)) { prefix := "method" method, found := strings.CutSuffix(name, "Request") if !found { @@ -228,7 +245,7 @@ import ( method, found = strings.CutSuffix(name, "Notification") } if found { - if ms, ok := s.Properties["method"]; ok { + if ms, ok := schema.Definitions[name].Properties["method"]; ok { if c := ms.Const; c != nil { fmt.Fprintf(buf, "%s%s = %q\n", prefix, method, *c) } @@ -395,9 +412,9 @@ func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named ma fieldTypeSchema = rs } needPointer := isStruct(fieldTypeSchema) - // Special case: there are no sampling capabilities defined, but - // we want it to be a struct for future expansion. - if !needPointer && name == "sampling" { + // Special case: there are no sampling or logging capabilities defined, + // but we want them to be structs for future expansion. + if !needPointer && (name == "sampling" || name == "logging") { needPointer = true } if config != nil && config.Fields[export] != nil { diff --git a/mcp/logging.go b/mcp/logging.go new file mode 100644 index 0000000..fb8e571 --- /dev/null +++ b/mcp/logging.go @@ -0,0 +1,189 @@ +// 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. + +package mcp + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "log/slog" + "sync" + "time" +) + +// Logging levels. +const ( + LevelDebug = slog.LevelDebug + LevelInfo = slog.LevelInfo + LevelNotice = (slog.LevelInfo + slog.LevelWarn) / 2 + LevelWarning = slog.LevelWarn + LevelError = slog.LevelError + LevelCritical = slog.LevelError + 4 + LevelAlert = slog.LevelError + 8 + LevelEmergency = slog.LevelError + 12 +) + +var slogToMCP = map[slog.Level]LoggingLevel{ + LevelDebug: "debug", + LevelInfo: "info", + LevelNotice: "notice", + LevelWarning: "warning", + LevelError: "error", + LevelCritical: "critical", + LevelAlert: "alert", + LevelEmergency: "emergency", +} + +var mcpToSlog = make(map[LoggingLevel]slog.Level) + +func init() { + for sl, ml := range slogToMCP { + mcpToSlog[ml] = sl + } +} + +func slogLevelToMCP(sl slog.Level) LoggingLevel { + if ml, ok := slogToMCP[sl]; ok { + return ml + } + return "debug" // for lack of a better idea +} + +func mcpLevelToSlog(ll LoggingLevel) slog.Level { + if sl, ok := mcpToSlog[ll]; ok { + return sl + } + // TODO: is there a better default? + return LevelDebug +} + +// compareLevels behaves like [cmp.Compare] for [LoggingLevel]s. +func compareLevels(l1, l2 LoggingLevel) int { + return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2)) +} + +// LoggingHandlerOptions are options for a LoggingHandler. +type LoggingHandlerOptions struct { + // The value for the "logger" field of logging notifications. + LoggerName string + // Limits the rate at which log messages are sent. + // If zero, there is no rate limiting. + MinInterval time.Duration +} + +// A LoggingHandler is a [slog.Handler] for MCP. +type LoggingHandler struct { + opts LoggingHandlerOptions + ss *ServerSession + // Ensures that the buffer reset is atomic with the write (see Handle). + // A pointer so that clones share the mutex. See + // https://github.com/golang/example/blob/master/slog-handler-guide/README.md#getting-the-mutex-right. + mu *sync.Mutex + lastMessageSent time.Time // for rate-limiting + buf *bytes.Buffer + handler slog.Handler +} + +// NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a +// [slog.JSONHandler]. +func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler { + var buf bytes.Buffer + jsonHandler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{ + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + // Remove level: it appears in LoggingMessageParams. + if a.Key == slog.LevelKey { + return slog.Attr{} + } + return a + }, + }) + lh := &LoggingHandler{ + ss: ss, + mu: new(sync.Mutex), + buf: &buf, + handler: jsonHandler, + } + if opts != nil { + lh.opts = *opts + } + return lh +} + +// Enabled implements [slog.Handler.Enabled] by comparing level to the [ServerSession]'s level. +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. + h.ss.mu.Lock() + mcpLevel := h.ss.logLevel + h.ss.mu.Unlock() + return level >= mcpLevelToSlog(mcpLevel) +} + +// WithAttrs implements [slog.Handler.WithAttrs]. +func (h *LoggingHandler) WithAttrs(as []slog.Attr) slog.Handler { + h2 := *h + h2.handler = h.handler.WithAttrs(as) + return &h2 +} + +// WithGroup implements [slog.Handler.WithGroup]. +func (h *LoggingHandler) WithGroup(name string) slog.Handler { + h2 := *h + h2.handler = h.handler.WithGroup(name) + return &h2 +} + +// Handle implements [slog.Handler.Handle] by writing the Record to a JSONHandler, +// then calling [ServerSession.LoggingMesssage] with the result. +func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error { + err := h.handle(ctx, r) + // TODO(jba): find a way to surface the error. + // The return value will probably be ignored. + return err +} + +func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { + // Observe the rate limit. + // TODO(jba): use golang.org/x/time/rate. (We can't here because it would require adding + // golang.org/x/time to the go.mod file.) + h.mu.Lock() + skip := time.Since(h.lastMessageSent) < h.opts.MinInterval + h.mu.Unlock() + if skip { + return nil + } + + var err error + // Make the buffer reset atomic with the record write. + // We are careful here in the unlikely event that the handler panics. + // We don't want to hold the lock for the entire function, because Notify is + // an I/O operation. + // This can result in out-of-order delivery. + func() { + h.mu.Lock() + defer h.mu.Unlock() + h.buf.Reset() + err = h.handler.Handle(ctx, r) + }() + if err != nil { + return err + } + + h.mu.Lock() + h.lastMessageSent = time.Now() + h.mu.Unlock() + + params := &LoggingMessageParams{ + Logger: h.opts.LoggerName, + Level: slogLevelToMCP(r.Level), + Data: json.RawMessage(h.buf.Bytes()), + } + // We pass the argument context to Notify, even though slog.Handler.Handle's + // documentation says not to. + // In this case logging is a service to clients, not a means for debugging the + // server, so we want to cancel the log message. + return h.ss.LoggingMessage(ctx, params) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 3dc74c5..6de25a4 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "log/slog" "path/filepath" "runtime" "slices" @@ -104,6 +105,7 @@ func TestEndToEnd(t *testing.T) { clientWG.Done() }() + loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging opts := &ClientOptions{ CreateMessageHandler: func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error) { return &CreateMessageResult{Model: "aModel"}, nil @@ -111,6 +113,9 @@ func TestEndToEnd(t *testing.T) { ToolListChangedHandler: func(context.Context, *ClientSession, *ToolListChangedParams) { notificationChans["tools"] <- 0 }, PromptListChangedHandler: func(context.Context, *ClientSession, *PromptListChangedParams) { notificationChans["prompts"] <- 0 }, ResourceListChangedHandler: func(context.Context, *ClientSession, *ResourceListChangedParams) { notificationChans["resources"] <- 0 }, + LoggingMessageHandler: func(_ context.Context, _ *ClientSession, lm *LoggingMessageParams) { + loggingMessages <- lm + }, } c := NewClient("testClient", "v1.0.0", opts) rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) @@ -319,6 +324,85 @@ func TestEndToEnd(t *testing.T) { t.Errorf("got %q, want %q", g, w) } }) + t.Run("logging", func(t *testing.T) { + want := []*LoggingMessageParams{ + { + Logger: "test", + Level: "warning", + Data: map[string]any{ + "msg": "first", + "name": "Pat", + "logtest": true, + }, + }, + { + Logger: "test", + Level: "alert", + Data: map[string]any{ + "msg": "second", + "count": 2.0, + "logtest": true, + }, + }, + } + + check := func(t *testing.T) { + t.Helper() + var got []*LoggingMessageParams + // Read messages from this test until we've seen all we expect. + for len(got) < len(want) { + select { + case p := <-loggingMessages: + // Ignore logging from other tests. + if m, ok := p.Data.(map[string]any); ok && m["logtest"] != nil { + delete(m, "time") // remove time because it changes + got = append(got, p) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for log messages") + } + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + } + + t.Run("direct", func(t *testing.T) { // Use the LoggingMessage method directly. + + mustLog := func(level LoggingLevel, data any) { + t.Helper() + if err := ss.LoggingMessage(ctx, &LoggingMessageParams{ + Logger: "test", + Level: level, + Data: data, + }); err != nil { + t.Fatal(err) + } + } + + // Nothing should be logged until the client sets a level. + mustLog("info", "before") + if err := cs.SetLevel(ctx, &SetLevelParams{Level: "warning"}); err != nil { + t.Fatal(err) + } + mustLog("warning", want[0].Data) + mustLog("debug", "nope") // below the level + mustLog("info", "negative") // below the level + mustLog("alert", want[1].Data) + check(t) + }) + + t.Run("handler", func(t *testing.T) { // Use the slog handler. + // We can't check the "before SetLevel" behavior because it's already been set. + // Not a big deal: that check is in LoggingMessage anyway. + logger := slog.New(NewLoggingHandler(ss, &LoggingHandlerOptions{LoggerName: "test"})) + logger.Warn("first", "name", "Pat", "logtest", true) + logger.Debug("nope") // below the level + logger.Info("negative") // below the level + logger.Log(ctx, LevelAlert, "second", "count", 2, "logtest", true) + check(t) + }) + }) // Disconnect. cs.Close() diff --git a/mcp/protocol.go b/mcp/protocol.go index 5dbec5f..ca424c4 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -241,6 +241,22 @@ type ListToolsResult struct { Tools []*Tool `json:"tools"` } +// The severity of a log message. +// +// These map to syslog message severities, as specified in RFC-5424: +// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +type LoggingLevel string + +type LoggingMessageParams struct { + // The data to be logged, such as a string message or an object. Any JSON + // serializable type is allowed here. + Data any `json:"data"` + // The severity of this log message. + Level LoggingLevel `json:"level"` + // An optional name of the logger issuing this message. + Logger string `json:"logger,omitempty"` +} + // Hints to use for model selection. // // Keys not declared here are currently left unspecified by the spec and are up @@ -326,8 +342,7 @@ type PromptArgument struct { type PromptListChangedParams struct { // This parameter name is reserved by MCP to allow clients and servers to attach // additional metadata to their notifications. - Meta struct { - } `json:"_meta,omitempty"` + Meta map[string]json.RawMessage `json:"_meta,omitempty"` } // Describes a message returned as part of a prompt. @@ -381,8 +396,7 @@ type Resource struct { type ResourceListChangedParams struct { // This parameter name is reserved by MCP to allow clients and servers to attach // additional metadata to their notifications. - Meta struct { - } `json:"_meta,omitempty"` + Meta map[string]json.RawMessage `json:"_meta,omitempty"` } // The sender or recipient of messages and data in a conversation. @@ -403,8 +417,7 @@ type Root struct { type RootsListChangedParams struct { // This parameter name is reserved by MCP to allow clients and servers to attach // additional metadata to their notifications. - Meta struct { - } `json:"_meta,omitempty"` + Meta map[string]json.RawMessage `json:"_meta,omitempty"` } // Present if the client supports sampling from an LLM. @@ -417,6 +430,13 @@ type SamplingMessage struct { Role Role `json:"role"` } +type SetLevelParams struct { + // The level of logging that the client wants to receive from the server. The + // server should send all logs at this level and higher (i.e., more severe) to + // the client as notifications/message. + Level LoggingLevel `json:"level"` +} + // Definition for a tool the client can call. type Tool struct { // Optional additional tool information. @@ -472,8 +492,7 @@ type ToolAnnotations struct { type ToolListChangedParams struct { // This parameter name is reserved by MCP to allow clients and servers to attach // additional metadata to their notifications. - Meta struct { - } `json:"_meta,omitempty"` + Meta map[string]json.RawMessage `json:"_meta,omitempty"` } // Describes the name and version of an MCP implementation. @@ -482,6 +501,10 @@ type implementation struct { Version string `json:"version"` } +// Present if the server supports sending log messages to the client. +type loggingCapabilities struct { +} + // Present if the server offers any prompt templates. type promptCapabilities struct { // Whether this server supports notifications for changes to the prompt list. @@ -507,8 +530,7 @@ type serverCapabilities struct { Experimental map[string]struct { } `json:"experimental,omitempty"` // Present if the server supports sending log messages to the client. - Logging struct { - } `json:"logging,omitempty"` + Logging *loggingCapabilities `json:"logging,omitempty"` // Present if the server offers any prompt templates. Prompts *promptCapabilities `json:"prompts,omitempty"` // Present if the server offers any resources to read. @@ -524,28 +546,28 @@ type toolCapabilities struct { } const ( - methodCreateMessage = "sampling/createMessage" - notificationToolListChanged = "notifications/tools/list_changed" - notificationResourceListChanged = "notifications/resources/list_changed" - methodListPrompts = "prompts/list" - notificationPromptListChanged = "notifications/prompts/list_changed" - notificationResourceUpdated = "notifications/resources/updated" - notificationCancelled = "notifications/cancelled" - methodSetLevel = "logging/setLevel" - methodInitialize = "initialize" - methodListRoots = "roots/list" - notificationProgress = "notifications/progress" - methodGetPrompt = "prompts/get" - methodListResourceTemplates = "resources/templates/list" - methodPing = "ping" - methodComplete = "completion/complete" - methodListResources = "resources/list" - notificationInitialized = "notifications/initialized" methodCallTool = "tools/call" - notificationLoggingMessage = "notifications/message" - methodReadResource = "resources/read" + notificationCancelled = "notifications/cancelled" + methodComplete = "completion/complete" + methodCreateMessage = "sampling/createMessage" + methodGetPrompt = "prompts/get" + methodInitialize = "initialize" + notificationInitialized = "notifications/initialized" + methodListPrompts = "prompts/list" + methodListResourceTemplates = "resources/templates/list" + methodListResources = "resources/list" + methodListRoots = "roots/list" methodListTools = "tools/list" + notificationLoggingMessage = "notifications/message" + methodPing = "ping" + notificationProgress = "notifications/progress" + notificationPromptListChanged = "notifications/prompts/list_changed" + methodReadResource = "resources/read" + notificationResourceListChanged = "notifications/resources/list_changed" + notificationResourceUpdated = "notifications/resources/updated" notificationRootsListChanged = "notifications/roots/list_changed" + methodSetLevel = "logging/setLevel" methodSubscribe = "resources/subscribe" + notificationToolListChanged = "notifications/tools/list_changed" methodUnsubscribe = "resources/unsubscribe" ) diff --git a/mcp/server.go b/mcp/server.go index 3253f0c..40fa913 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -299,11 +299,11 @@ func (s *Server) Run(ctx context.Context, t Transport) error { // bind implements the binder[*ServerSession] interface, so that Servers can // be connected using [connect]. func (s *Server) bind(conn *jsonrpc2.Connection) *ServerSession { - cc := &ServerSession{conn: conn, server: s} + ss := &ServerSession{conn: conn, server: s} s.mu.Lock() - s.sessions = append(s.sessions, cc) + s.sessions = append(s.sessions, ss) s.mu.Unlock() - return cc + return ss } // disconnect implements the binder[*ServerSession] interface, so that @@ -341,17 +341,17 @@ func (s *Server) callRootsListChangedHandler(ctx context.Context, ss *ServerSess // Call [ServerSession.Close] to close the connection, or await client // termination with [ServerSession.Wait]. type ServerSession struct { - server *Server - conn *jsonrpc2.Connection - + server *Server + conn *jsonrpc2.Connection mu sync.Mutex + logLevel LoggingLevel initializeParams *InitializeParams initialized bool } // Ping pings the client. func (ss *ServerSession) Ping(ctx context.Context, _ *PingParams) error { - return call(ctx, ss.conn, "ping", (*PingParams)(nil), nil) + return call(ctx, ss.conn, methodPing, (*PingParams)(nil), nil) } // ListRoots lists the client roots. @@ -364,6 +364,25 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag return standardCall[CreateMessageResult](ctx, ss.conn, methodCreateMessage, params) } +// LoggingMessage sends a logging message to the client. +// The message is not sent if the client has not called SetLevel, or if its level +// is below that of the last SetLevel. +func (ss *ServerSession) LoggingMessage(ctx context.Context, params *LoggingMessageParams) error { + ss.mu.Lock() + logLevel := ss.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. + // TODO(jba): read other SDKs, possibly file an issue. + return nil + } + if compareLevels(params.Level, logLevel) < 0 { + return nil + } + return ss.conn.Notify(ctx, notificationLoggingMessage, params) +} + // AddMiddleware wraps the server's current method handler using the provided // middleware. Middleware is applied from right to left, so that the first one // is executed first. @@ -386,6 +405,7 @@ var serverMethodInfos = map[string]methodInfo[ServerSession]{ methodCallTool: newMethodInfo(serverMethod((*Server).callTool)), methodListResources: newMethodInfo(serverMethod((*Server).listResources)), methodReadResource: newMethodInfo(serverMethod((*Server).readResource)), + methodSetLevel: newMethodInfo(sessionMethod((*ServerSession).setLevel)), notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler)), notificationRootsListChanged: newMethodInfo(serverMethod((*Server).callRootsListChangedHandler)), } @@ -457,6 +477,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam Resources: &resourceCapabilities{ ListChanged: true, }, + Logging: &loggingCapabilities{}, }, Instructions: ss.server.opts.Instructions, ServerInfo: &implementation{ @@ -470,6 +491,13 @@ func (ss *ServerSession) ping(context.Context, *PingParams) (struct{}, error) { return struct{}{}, nil } +func (ss *ServerSession) setLevel(_ context.Context, params *SetLevelParams) (struct{}, error) { + ss.mu.Lock() + defer ss.mu.Unlock() + ss.logLevel = params.Level + return struct{}{}, nil +} + // Close performs a graceful shutdown of the connection, preventing new // requests from being handled, and waiting for ongoing requests to return. // Close then terminates the connection.