go-sdk/examples/server/middleware/main.go
José M. Requena Plens 3f43bcf012
mcp: add HasParams so middleware can tell whether params are present (#1269)
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>
2026-09-14 18:45:48 +02:00

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!
}