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 <jba@google.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Sam Thanawalla <samthanawalla@google.com> Reviewed-by: Robert Findley <rfindley@google.com>
This commit is contained in:
parent
bd2af6e0b1
commit
edda031578
7 changed files with 416 additions and 42 deletions
|
|
@ -823,6 +823,27 @@ Clients call the spec method `Complete` to request completions. Servers automati
|
||||||
|
|
||||||
### Logging
|
### 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`:
|
Server-to-client logging is configured with `ServerOptions`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -832,24 +853,24 @@ type ServerOptions {
|
||||||
LoggerName string
|
LoggerName string
|
||||||
// Log notifications to a single ClientSession will not be
|
// Log notifications to a single ClientSession will not be
|
||||||
// sent more frequently than this duration.
|
// 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.
|
- 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.
|
- 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:
|
A client that wishes to receive log messages must provide a handler:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type ClientOptions struct {
|
type ClientOptions struct {
|
||||||
...
|
...
|
||||||
LogMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams)
|
LoggingMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ type ClientOptions struct {
|
||||||
ToolListChangedHandler func(context.Context, *ClientSession, *ToolListChangedParams)
|
ToolListChangedHandler func(context.Context, *ClientSession, *ToolListChangedParams)
|
||||||
PromptListChangedHandler func(context.Context, *ClientSession, *PromptListChangedParams)
|
PromptListChangedHandler func(context.Context, *ClientSession, *PromptListChangedParams)
|
||||||
ResourceListChangedHandler func(context.Context, *ClientSession, *ResourceListChangedParams)
|
ResourceListChangedHandler func(context.Context, *ClientSession, *ResourceListChangedParams)
|
||||||
|
LoggingMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bind implements the binder[*ClientSession] interface, so that Clients can
|
// 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)),
|
notificationToolListChanged: newMethodInfo(clientMethod((*Client).callToolChangedHandler)),
|
||||||
notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler)),
|
notificationPromptListChanged: newMethodInfo(clientMethod((*Client).callPromptChangedHandler)),
|
||||||
notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler)),
|
notificationResourceListChanged: newMethodInfo(clientMethod((*Client).callResourceChangedHandler)),
|
||||||
|
notificationLoggingMessage: newMethodInfo(clientMethod((*Client).callLoggingHandler)),
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ session[ClientSession] = (*ClientSession)(nil)
|
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)
|
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.
|
// NOTE: the following struct should consist of all fields of callToolParams except name and arguments.
|
||||||
|
|
||||||
// CallToolOptions contains options to [ClientSession.CallTool].
|
// 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)
|
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) {
|
func standardCall[TRes, TParams any](ctx context.Context, conn *jsonrpc2.Connection, method string, params TParams) (*TRes, error) {
|
||||||
var result TRes
|
var result TRes
|
||||||
if err := call(ctx, conn, method, params, &result); err != nil {
|
if err := call(ctx, conn, method, params, &result); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"go/format"
|
"go/format"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
@ -110,7 +111,18 @@ var declarations = config{
|
||||||
Name: "-",
|
Name: "-",
|
||||||
Fields: config{"Params": {Name: "ListToolsParams"}},
|
Fields: config{"Params": {Name: "ListToolsParams"}},
|
||||||
},
|
},
|
||||||
"ListToolsResult": {},
|
"ListToolsResult": {},
|
||||||
|
"loggingCapabilities": {Substitute: "struct{}"},
|
||||||
|
"LoggingLevel": {},
|
||||||
|
"LoggingMessageNotification": {
|
||||||
|
Name: "-",
|
||||||
|
Fields: config{
|
||||||
|
"Params": {
|
||||||
|
Name: "LoggingMessageParams",
|
||||||
|
Fields: config{"Data": {Substitute: "any"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"ModelHint": {},
|
"ModelHint": {},
|
||||||
"ModelPreferences": {},
|
"ModelPreferences": {},
|
||||||
"PingRequest": {
|
"PingRequest": {
|
||||||
|
|
@ -153,8 +165,13 @@ var declarations = config{
|
||||||
"Prompts": {Name: "promptCapabilities"},
|
"Prompts": {Name: "promptCapabilities"},
|
||||||
"Resources": {Name: "resourceCapabilities"},
|
"Resources": {Name: "resourceCapabilities"},
|
||||||
"Tools": {Name: "toolCapabilities"},
|
"Tools": {Name: "toolCapabilities"},
|
||||||
|
"Logging": {Name: "loggingCapabilities"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"SetLevelRequest": {
|
||||||
|
Name: "-",
|
||||||
|
Fields: config{"Params": {Name: "SetLevelParams"}},
|
||||||
|
},
|
||||||
"Tool": {
|
"Tool": {
|
||||||
Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}},
|
Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}},
|
||||||
},
|
},
|
||||||
|
|
@ -220,7 +237,7 @@ import (
|
||||||
}
|
}
|
||||||
// Write out method names.
|
// Write out method names.
|
||||||
fmt.Fprintln(buf, `const (`)
|
fmt.Fprintln(buf, `const (`)
|
||||||
for name, s := range schema.Definitions {
|
for _, name := range slices.Sorted(maps.Keys(schema.Definitions)) {
|
||||||
prefix := "method"
|
prefix := "method"
|
||||||
method, found := strings.CutSuffix(name, "Request")
|
method, found := strings.CutSuffix(name, "Request")
|
||||||
if !found {
|
if !found {
|
||||||
|
|
@ -228,7 +245,7 @@ import (
|
||||||
method, found = strings.CutSuffix(name, "Notification")
|
method, found = strings.CutSuffix(name, "Notification")
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
if ms, ok := s.Properties["method"]; ok {
|
if ms, ok := schema.Definitions[name].Properties["method"]; ok {
|
||||||
if c := ms.Const; c != nil {
|
if c := ms.Const; c != nil {
|
||||||
fmt.Fprintf(buf, "%s%s = %q\n", prefix, method, *c)
|
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
|
fieldTypeSchema = rs
|
||||||
}
|
}
|
||||||
needPointer := isStruct(fieldTypeSchema)
|
needPointer := isStruct(fieldTypeSchema)
|
||||||
// Special case: there are no sampling capabilities defined, but
|
// Special case: there are no sampling or logging capabilities defined,
|
||||||
// we want it to be a struct for future expansion.
|
// but we want them to be structs for future expansion.
|
||||||
if !needPointer && name == "sampling" {
|
if !needPointer && (name == "sampling" || name == "logging") {
|
||||||
needPointer = true
|
needPointer = true
|
||||||
}
|
}
|
||||||
if config != nil && config.Fields[export] != nil {
|
if config != nil && config.Fields[export] != nil {
|
||||||
|
|
|
||||||
189
mcp/logging.go
Normal file
189
mcp/logging.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -104,6 +105,7 @@ func TestEndToEnd(t *testing.T) {
|
||||||
clientWG.Done()
|
clientWG.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging
|
||||||
opts := &ClientOptions{
|
opts := &ClientOptions{
|
||||||
CreateMessageHandler: func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error) {
|
CreateMessageHandler: func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error) {
|
||||||
return &CreateMessageResult{Model: "aModel"}, nil
|
return &CreateMessageResult{Model: "aModel"}, nil
|
||||||
|
|
@ -111,6 +113,9 @@ func TestEndToEnd(t *testing.T) {
|
||||||
ToolListChangedHandler: func(context.Context, *ClientSession, *ToolListChangedParams) { notificationChans["tools"] <- 0 },
|
ToolListChangedHandler: func(context.Context, *ClientSession, *ToolListChangedParams) { notificationChans["tools"] <- 0 },
|
||||||
PromptListChangedHandler: func(context.Context, *ClientSession, *PromptListChangedParams) { notificationChans["prompts"] <- 0 },
|
PromptListChangedHandler: func(context.Context, *ClientSession, *PromptListChangedParams) { notificationChans["prompts"] <- 0 },
|
||||||
ResourceListChangedHandler: func(context.Context, *ClientSession, *ResourceListChangedParams) { notificationChans["resources"] <- 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)
|
c := NewClient("testClient", "v1.0.0", opts)
|
||||||
rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files"))
|
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.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.
|
// Disconnect.
|
||||||
cs.Close()
|
cs.Close()
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,22 @@ type ListToolsResult struct {
|
||||||
Tools []*Tool `json:"tools"`
|
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.
|
// Hints to use for model selection.
|
||||||
//
|
//
|
||||||
// Keys not declared here are currently left unspecified by the spec and are up
|
// Keys not declared here are currently left unspecified by the spec and are up
|
||||||
|
|
@ -326,8 +342,7 @@ type PromptArgument struct {
|
||||||
type PromptListChangedParams struct {
|
type PromptListChangedParams struct {
|
||||||
// This parameter name is reserved by MCP to allow clients and servers to attach
|
// This parameter name is reserved by MCP to allow clients and servers to attach
|
||||||
// additional metadata to their notifications.
|
// additional metadata to their notifications.
|
||||||
Meta struct {
|
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||||
} `json:"_meta,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Describes a message returned as part of a prompt.
|
// Describes a message returned as part of a prompt.
|
||||||
|
|
@ -381,8 +396,7 @@ type Resource struct {
|
||||||
type ResourceListChangedParams struct {
|
type ResourceListChangedParams struct {
|
||||||
// This parameter name is reserved by MCP to allow clients and servers to attach
|
// This parameter name is reserved by MCP to allow clients and servers to attach
|
||||||
// additional metadata to their notifications.
|
// additional metadata to their notifications.
|
||||||
Meta struct {
|
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||||
} `json:"_meta,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sender or recipient of messages and data in a conversation.
|
// The sender or recipient of messages and data in a conversation.
|
||||||
|
|
@ -403,8 +417,7 @@ type Root struct {
|
||||||
type RootsListChangedParams struct {
|
type RootsListChangedParams struct {
|
||||||
// This parameter name is reserved by MCP to allow clients and servers to attach
|
// This parameter name is reserved by MCP to allow clients and servers to attach
|
||||||
// additional metadata to their notifications.
|
// additional metadata to their notifications.
|
||||||
Meta struct {
|
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||||
} `json:"_meta,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Present if the client supports sampling from an LLM.
|
// Present if the client supports sampling from an LLM.
|
||||||
|
|
@ -417,6 +430,13 @@ type SamplingMessage struct {
|
||||||
Role Role `json:"role"`
|
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.
|
// Definition for a tool the client can call.
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
// Optional additional tool information.
|
// Optional additional tool information.
|
||||||
|
|
@ -472,8 +492,7 @@ type ToolAnnotations struct {
|
||||||
type ToolListChangedParams struct {
|
type ToolListChangedParams struct {
|
||||||
// This parameter name is reserved by MCP to allow clients and servers to attach
|
// This parameter name is reserved by MCP to allow clients and servers to attach
|
||||||
// additional metadata to their notifications.
|
// additional metadata to their notifications.
|
||||||
Meta struct {
|
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||||
} `json:"_meta,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Describes the name and version of an MCP implementation.
|
// Describes the name and version of an MCP implementation.
|
||||||
|
|
@ -482,6 +501,10 @@ type implementation struct {
|
||||||
Version string `json:"version"`
|
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.
|
// Present if the server offers any prompt templates.
|
||||||
type promptCapabilities struct {
|
type promptCapabilities struct {
|
||||||
// Whether this server supports notifications for changes to the prompt list.
|
// Whether this server supports notifications for changes to the prompt list.
|
||||||
|
|
@ -507,8 +530,7 @@ type serverCapabilities struct {
|
||||||
Experimental map[string]struct {
|
Experimental map[string]struct {
|
||||||
} `json:"experimental,omitempty"`
|
} `json:"experimental,omitempty"`
|
||||||
// Present if the server supports sending log messages to the client.
|
// Present if the server supports sending log messages to the client.
|
||||||
Logging struct {
|
Logging *loggingCapabilities `json:"logging,omitempty"`
|
||||||
} `json:"logging,omitempty"`
|
|
||||||
// Present if the server offers any prompt templates.
|
// Present if the server offers any prompt templates.
|
||||||
Prompts *promptCapabilities `json:"prompts,omitempty"`
|
Prompts *promptCapabilities `json:"prompts,omitempty"`
|
||||||
// Present if the server offers any resources to read.
|
// Present if the server offers any resources to read.
|
||||||
|
|
@ -524,28 +546,28 @@ type toolCapabilities struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
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"
|
methodCallTool = "tools/call"
|
||||||
notificationLoggingMessage = "notifications/message"
|
notificationCancelled = "notifications/cancelled"
|
||||||
methodReadResource = "resources/read"
|
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"
|
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"
|
notificationRootsListChanged = "notifications/roots/list_changed"
|
||||||
|
methodSetLevel = "logging/setLevel"
|
||||||
methodSubscribe = "resources/subscribe"
|
methodSubscribe = "resources/subscribe"
|
||||||
|
notificationToolListChanged = "notifications/tools/list_changed"
|
||||||
methodUnsubscribe = "resources/unsubscribe"
|
methodUnsubscribe = "resources/unsubscribe"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -299,11 +299,11 @@ func (s *Server) Run(ctx context.Context, t Transport) error {
|
||||||
// bind implements the binder[*ServerSession] interface, so that Servers can
|
// bind implements the binder[*ServerSession] interface, so that Servers can
|
||||||
// be connected using [connect].
|
// be connected using [connect].
|
||||||
func (s *Server) bind(conn *jsonrpc2.Connection) *ServerSession {
|
func (s *Server) bind(conn *jsonrpc2.Connection) *ServerSession {
|
||||||
cc := &ServerSession{conn: conn, server: s}
|
ss := &ServerSession{conn: conn, server: s}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.sessions = append(s.sessions, cc)
|
s.sessions = append(s.sessions, ss)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return cc
|
return ss
|
||||||
}
|
}
|
||||||
|
|
||||||
// disconnect implements the binder[*ServerSession] interface, so that
|
// 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
|
// Call [ServerSession.Close] to close the connection, or await client
|
||||||
// termination with [ServerSession.Wait].
|
// termination with [ServerSession.Wait].
|
||||||
type ServerSession struct {
|
type ServerSession struct {
|
||||||
server *Server
|
server *Server
|
||||||
conn *jsonrpc2.Connection
|
conn *jsonrpc2.Connection
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
logLevel LoggingLevel
|
||||||
initializeParams *InitializeParams
|
initializeParams *InitializeParams
|
||||||
initialized bool
|
initialized bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping pings the client.
|
// Ping pings the client.
|
||||||
func (ss *ServerSession) Ping(ctx context.Context, _ *PingParams) error {
|
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.
|
// 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)
|
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
|
// AddMiddleware wraps the server's current method handler using the provided
|
||||||
// middleware. Middleware is applied from right to left, so that the first one
|
// middleware. Middleware is applied from right to left, so that the first one
|
||||||
// is executed first.
|
// is executed first.
|
||||||
|
|
@ -386,6 +405,7 @@ var serverMethodInfos = map[string]methodInfo[ServerSession]{
|
||||||
methodCallTool: newMethodInfo(serverMethod((*Server).callTool)),
|
methodCallTool: newMethodInfo(serverMethod((*Server).callTool)),
|
||||||
methodListResources: newMethodInfo(serverMethod((*Server).listResources)),
|
methodListResources: newMethodInfo(serverMethod((*Server).listResources)),
|
||||||
methodReadResource: newMethodInfo(serverMethod((*Server).readResource)),
|
methodReadResource: newMethodInfo(serverMethod((*Server).readResource)),
|
||||||
|
methodSetLevel: newMethodInfo(sessionMethod((*ServerSession).setLevel)),
|
||||||
notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler)),
|
notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler)),
|
||||||
notificationRootsListChanged: newMethodInfo(serverMethod((*Server).callRootsListChangedHandler)),
|
notificationRootsListChanged: newMethodInfo(serverMethod((*Server).callRootsListChangedHandler)),
|
||||||
}
|
}
|
||||||
|
|
@ -457,6 +477,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam
|
||||||
Resources: &resourceCapabilities{
|
Resources: &resourceCapabilities{
|
||||||
ListChanged: true,
|
ListChanged: true,
|
||||||
},
|
},
|
||||||
|
Logging: &loggingCapabilities{},
|
||||||
},
|
},
|
||||||
Instructions: ss.server.opts.Instructions,
|
Instructions: ss.server.opts.Instructions,
|
||||||
ServerInfo: &implementation{
|
ServerInfo: &implementation{
|
||||||
|
|
@ -470,6 +491,13 @@ func (ss *ServerSession) ping(context.Context, *PingParams) (struct{}, error) {
|
||||||
return struct{}{}, nil
|
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
|
// Close performs a graceful shutdown of the connection, preventing new
|
||||||
// requests from being handled, and waiting for ongoing requests to return.
|
// requests from being handled, and waiting for ongoing requests to return.
|
||||||
// Close then terminates the connection.
|
// Close then terminates the connection.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue