This adds `mcp.HasParams(req Request) bool`, so a receiving middleware
can ask whether the message it was handed carried a `params` member.
The member is optional for several methods: `serverMethodInfos` sets
`missingParamsOK` for `server/discover`, `ping`, the four list methods
and the `cancelled`, `initialized` and `roots/list_changed`
notifications, so `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` is a
complete request. For one of those, `unmarshalParams` returns a nil
`Params`, `newRequest` leaves `ServerRequest.Params` at the zero value
of `P`, and `GetParams` hands the middleware a non-nil interface holding
a typed nil pointer. Comparing that against nil says params are present,
and the first method called on it dereferences: `GetMeta` is promoted
from the embedded `Meta`. `Params` already declares `isNil` for
precisely this case, and it is unexported, so from outside the package
the accessor named after the question is the one thing that cannot be
reached.
`HasParams` is a function over `Request` rather than an exported method
on `Params` for two reasons. A middleware holds a `Request`, and this
way the caller never has to hold the value that panics. More
importantly, `isNil` cannot answer the question for every `Params`
value, so exporting it would ship a predicate that is itself unsafe: a
params type declared the documented way for a custom method, `struct{
mcp.ParamsBase }`, promotes `isNil` through a field selector, and that
dereferences the nil outer pointer before the method body runs. The
predicate therefore has to be computed from outside the value, and
`reflect.Value.IsNil` is what works for all of them.
Three judgement calls, stated so nobody has to find them.
First, `getRequestMeta` now goes through `HasParams`. That is a second
fix rather than a refactor: `AddReceivingCustomMethod` registers with
`missingParamsOK` too, so a client omitting `params` for a custom method
made `ServerRequest.ProtocolVersion`, `ClientInfo` and
`ClientCapabilities` panic in the promoted `isNil`. They now return
`""`, `nil` and `nil`. I could have left it out to keep the diff to one
function, but then the SDK would export a predicate that says to ask
before touching params while three of its own accessors still panicked
on the same value. If you would rather see it as a separate commit, say
so and I will split it.
Second, I did not add the predicate to the `Request` interface.
`Request` is sealed by `isRequest`, so a method would have been a
compatible change and would read slightly better at the call site, but a
free function leaves the interface alone and serves every `Request` just
as well.
Third, this reflects once per call of `getRequestMeta`. It is one
`reflect.ValueOf` on a value that is already an interface, on a path
where `newResult` already reflects per request.
Tests, both in `mcp/shared_test.go`.
`TestServerRequest_PerRequestAccessors_MissingParams` is the one that
fails before the change and passes after: on `main` it panics inside
`getRequestMeta` with `invalid memory address or nil pointer
dereference` from `(*customParams).isNil`, and here it asserts the three
accessors return zero values. `TestHasParams` covers the exported
behaviour: it builds exactly what `handleReceive` hands the middleware
chain for `tools/list`, `prompts/list`, `resources/list` and
`notifications/initialized` with no `params` member, asserts that
`GetParams` still returns a non-nil interface so the case under test is
still the typed-nil one, asserts `HasParams` reports false, and asserts
it reports true when params are present.
Behaviour an existing user would see change: only the three accessors
above, and only for a custom-method params type built on `ParamsBase`
whose `params` member was omitted, where a panic becomes a zero value.
Nothing about the wire format changes, no exported signature moves, and
no interface gains a method. A middleware that still writes
`req.GetParams() != nil` and then dereferences still panics; this gives
a caller the way to ask, it does not rescue a caller that does not.
Also here: one sentence on the doc comments of both
`AddReceivingMiddleware` methods saying a received message need not
carry params, since that is where a middleware author is reading, and
`examples/server/middleware/main.go` now logs `has_params` with
`mcp.HasParams` instead of the comparison that cannot work. The
example's printed output is unchanged, because the SDK client always
sends a `params` member for the methods it drives.
`go test ./...` and `gofmt -l .` are clean.
One thing to say plainly, since it is yours to decide and not mine: this
adds an exported symbol. `HasParams` is a new name in the public API of
`mcp`, and everything else here is doc comments, an example and the
three accessors. If you would rather not grow the surface for this, the
smaller answer is to keep the `getRequestMeta` fix and the doc-comment
sentence and drop the exported function, and a middleware author then
has the guarantee written down where they are reading even though they
still cannot ask the question in code. I prefer the function because the
guarantee alone leaves every middleware writing the same three lines of
`reflect`, and the one that does not write them panics on an ordinary
`tools/list`, but I will cut it to whichever shape you want.
Fixes #1261.
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
148 lines
4.5 KiB
Go
148 lines
4.5 KiB
Go
// 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 main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// This example demonstrates server side logging using the mcp.Middleware system.
|
|
func main() {
|
|
// Create a logger for demonstration purposes.
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
|
Level: slog.LevelInfo,
|
|
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
|
// Simplify timestamp format for consistent output.
|
|
if a.Key == slog.TimeKey {
|
|
return slog.String("time", "2025-01-01T00:00:00Z")
|
|
}
|
|
return a
|
|
},
|
|
}))
|
|
|
|
loggingMiddleware := func(next mcp.MethodHandler) mcp.MethodHandler {
|
|
return func(
|
|
ctx context.Context,
|
|
method string,
|
|
req mcp.Request,
|
|
) (mcp.Result, error) {
|
|
logger.Info("MCP method started",
|
|
"method", method,
|
|
"session_id", req.GetSession().ID(),
|
|
"has_params", mcp.HasParams(req),
|
|
)
|
|
// Log more for tool calls.
|
|
if ctr, ok := req.(*mcp.CallToolRequest); ok {
|
|
logger.Info("Calling tool",
|
|
"name", ctr.Params.Name,
|
|
"args", ctr.Params.Arguments)
|
|
}
|
|
|
|
start := time.Now()
|
|
result, err := next(ctx, method, req)
|
|
duration := time.Since(start)
|
|
if err != nil {
|
|
logger.Error("MCP method failed",
|
|
"method", method,
|
|
"session_id", req.GetSession().ID(),
|
|
"duration_ms", duration.Milliseconds(),
|
|
"err", err,
|
|
)
|
|
} else {
|
|
logger.Info("MCP method completed",
|
|
"method", method,
|
|
"session_id", req.GetSession().ID(),
|
|
"duration_ms", duration.Milliseconds(),
|
|
"has_result", result != nil,
|
|
)
|
|
// Log more for tool results.
|
|
if ctr, ok := result.(*mcp.CallToolResult); ok {
|
|
logger.Info("tool result",
|
|
"isError", ctr.IsError,
|
|
"structuredContent", ctr.StructuredContent)
|
|
}
|
|
}
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
// Create server with middleware
|
|
server := mcp.NewServer(&mcp.Implementation{Name: "logging-example"}, nil)
|
|
server.AddReceivingMiddleware(loggingMiddleware)
|
|
|
|
// Add a simple tool
|
|
mcp.AddTool(server,
|
|
&mcp.Tool{
|
|
Name: "greet",
|
|
Description: "Greet someone with logging.",
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"name": {
|
|
Type: "string",
|
|
Description: "Name to greet",
|
|
},
|
|
},
|
|
Required: []string{"name"},
|
|
},
|
|
},
|
|
func(
|
|
ctx context.Context,
|
|
req *mcp.CallToolRequest, args map[string]any,
|
|
) (*mcp.CallToolResult, any, error) {
|
|
name, ok := args["name"].(string)
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("name parameter is required and must be a string")
|
|
}
|
|
|
|
message := fmt.Sprintf("Hello, %s!", name)
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
&mcp.TextContent{Text: message},
|
|
},
|
|
}, message, nil
|
|
},
|
|
)
|
|
|
|
// Create client-server connection for demonstration
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
|
|
clientTransport, serverTransport := mcp.NewInMemoryTransports()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Connect server and client
|
|
serverSession, _ := server.Connect(ctx, serverTransport, nil)
|
|
defer serverSession.Close()
|
|
|
|
clientSession, _ := client.Connect(ctx, clientTransport, nil)
|
|
defer clientSession.Close()
|
|
|
|
// Call the tool to demonstrate logging
|
|
result, _ := clientSession.CallTool(ctx, &mcp.CallToolParams{
|
|
Name: "greet",
|
|
Arguments: map[string]any{
|
|
"name": "World",
|
|
},
|
|
})
|
|
|
|
fmt.Printf("Tool result: %s\n", result.Content[0].(*mcp.TextContent).Text)
|
|
|
|
// Output:
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="MCP method started" method=initialize session_id="" has_params=true
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="MCP method completed" method=initialize session_id="" duration_ms=0 has_result=true
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="MCP method started" method=notifications/initialized session_id="" has_params=true
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="MCP method completed" method=notifications/initialized session_id="" duration_ms=0 has_result=false
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="MCP method started" method=tools/call session_id="" has_params=true
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="Calling tool" name=greet args="{\"name\":\"World\"}"
|
|
// time=2025-01-01T00:00:00Z level=INFO msg="MCP method completed" method=tools/call session_id="" duration_ms=0 has_result=true
|
|
// Tool result: Hello, World!
|
|
}
|