From 9a1d8bb1f37078275196ab5dd97b191fe1d4ae0c Mon Sep 17 00:00:00 2001 From: ContextMatrix Runner Date: Tue, 12 May 2026 21:26:26 +0000 Subject: [PATCH 1/4] feat(mcp): log JSON-RPC method and tool name on per-request slog line - Add MCPCall struct and WithMCPCall/MCPCallFromContext helpers to ctxlog - observe middleware injects *MCPCall into context for /mcp requests and appends mcp_method/mcp_tool fields after ServeHTTP returns - mcpRequestInfoMiddleware in mcp/server.go reads+restores body, parses the JSON-RPC envelope, and populates the MCPCall pointer - Wire mcpRequestInfoMiddleware between clearWriteDeadlineForStreaming and the SDK handler inside NewHandler - Add TestWithMCPCall_roundtrip and TestMCPCallFromContext_missing to ctxlog - Add TestMCPRequestInfoMiddleware table test covering tools/call, other methods, malformed JSON, GET no-op, and nil-context defensive path --- internal/api/router.go | 23 +++++- internal/ctxlog/ctxlog.go | 28 ++++++++ internal/ctxlog/ctxlog_test.go | 21 ++++++ internal/mcp/logging_test.go | 125 +++++++++++++++++++++++++++++++++ internal/mcp/server.go | 70 ++++++++++++++++-- 5 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 internal/mcp/logging_test.go diff --git a/internal/api/router.go b/internal/api/router.go index b8432e91..416f204c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2,6 +2,7 @@ package api import ( + "context" "encoding/json" "errors" "log/slog" @@ -307,6 +308,17 @@ func observe(next http.Handler) http.Handler { return } + // For MCP requests, stash an MCPCall pointer in context so that the + // inner mcpRequestInfoMiddleware can populate method/tool after parsing + // the JSON-RPC body. We hold the pointer here so we can read it back + // after ServeHTTP returns to append mcp_method/mcp_tool to the log line. + var mcpCall *ctxlog.MCPCall + if r.URL.Path == "/mcp" { + var ctx context.Context + ctx, mcpCall = ctxlog.WithMCPCall(r.Context()) + r = r.WithContext(ctx) + } + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} start := time.Now() @@ -314,12 +326,19 @@ func observe(next http.Handler) http.Handler { dur := time.Since(start) - ctxlog.Logger(r.Context()).Info("request", + attrs := []any{ "method", r.Method, "path", r.URL.Path, "status", rw.statusCode, "duration_ms", dur.Milliseconds(), - ) + } + if mcpCall != nil && mcpCall.Method != "" { + attrs = append(attrs, "mcp_method", mcpCall.Method) + if mcpCall.Tool != "" { + attrs = append(attrs, "mcp_tool", mcpCall.Tool) + } + } + ctxlog.Logger(r.Context()).Info("request", attrs...) // SSE streams would pollute the REST latency histogram and the // path label set — skip them entirely for metrics. MCP Streamable diff --git a/internal/ctxlog/ctxlog.go b/internal/ctxlog/ctxlog.go index 3e6e1d4d..c94965f5 100644 --- a/internal/ctxlog/ctxlog.go +++ b/internal/ctxlog/ctxlog.go @@ -12,6 +12,34 @@ import ( // contextKey is an unexported type for context keys in this package. type contextKey struct{} +// mcpCallKey is the context key for storing *MCPCall. +type mcpCallKey struct{} + +// MCPCall holds MCP-specific request metadata populated by mcpRequestInfoMiddleware. +// Both fields start empty and are filled in after the JSON-RPC body is parsed. +type MCPCall struct { + Method string // JSON-RPC method (e.g. "tools/call", "notifications/initialized") + Tool string // tool name for tools/call requests; empty otherwise +} + +// WithMCPCall stashes an empty *MCPCall in ctx and returns both the enriched +// context and the pointer. The caller (observe middleware) holds the pointer so +// downstream middleware (mcpRequestInfoMiddleware) can mutate it, and the +// pointer is readable after ServeHTTP returns. +func WithMCPCall(ctx context.Context) (context.Context, *MCPCall) { + call := &MCPCall{} + + return context.WithValue(ctx, mcpCallKey{}, call), call +} + +// MCPCallFromContext retrieves the *MCPCall stored by WithMCPCall, or nil if +// no value is present (e.g. non-MCP requests or tests that skip middleware). +func MCPCallFromContext(ctx context.Context) *MCPCall { + call, _ := ctx.Value(mcpCallKey{}).(*MCPCall) + + return call +} + // WithRequestID returns a new context that carries a *slog.Logger // derived from slog.Default() with the "request_id" attribute set to id. // Retrieve the logger with Logger(ctx). diff --git a/internal/ctxlog/ctxlog_test.go b/internal/ctxlog/ctxlog_test.go index 33db64ba..d62eadd7 100644 --- a/internal/ctxlog/ctxlog_test.go +++ b/internal/ctxlog/ctxlog_test.go @@ -54,6 +54,27 @@ func TestLogger_fallback(t *testing.T) { "Logger on empty context should return slog.Default()") } +func TestWithMCPCall_roundtrip(t *testing.T) { + ctx, call := ctxlog.WithMCPCall(context.Background()) + require.NotNil(t, call) + + // Mutate via the returned pointer. + call.Method = "tools/call" + call.Tool = "claim_card" + + // Retrieving via context should return the same pointer, not a copy. + got := ctxlog.MCPCallFromContext(ctx) + require.NotNil(t, got) + assert.Same(t, call, got, "MCPCallFromContext should return the same pointer") + assert.Equal(t, "tools/call", got.Method) + assert.Equal(t, "claim_card", got.Tool) +} + +func TestMCPCallFromContext_missing(t *testing.T) { + got := ctxlog.MCPCallFromContext(context.Background()) + assert.Nil(t, got, "MCPCallFromContext on a bare context should return nil") +} + func TestWithRequestID_differentIDs(t *testing.T) { restoreSlogDefault(t) diff --git a/internal/mcp/logging_test.go b/internal/mcp/logging_test.go new file mode 100644 index 00000000..e8b39f35 --- /dev/null +++ b/internal/mcp/logging_test.go @@ -0,0 +1,125 @@ +package mcp + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mhersson/contextmatrix/internal/ctxlog" +) + +// TestMCPRequestInfoMiddleware verifies that mcpRequestInfoMiddleware correctly +// extracts JSON-RPC method and tool name from the request body and populates the +// MCPCall stored in context, while always restoring the body for downstream handlers. +func TestMCPRequestInfoMiddleware(t *testing.T) { + tests := []struct { + name string + httpMethod string + body string + wantMethod string + wantTool string + bodyInContext bool // whether to inject MCPCall into the context + }{ + { + name: "tools/call body populates method and tool", + httpMethod: http.MethodPost, + body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"claim_card"}}`, + wantMethod: "tools/call", + wantTool: "claim_card", + bodyInContext: true, + }, + { + name: "notifications/initialized body populates method only", + httpMethod: http.MethodPost, + body: `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`, + wantMethod: "notifications/initialized", + wantTool: "", + bodyInContext: true, + }, + { + name: "malformed JSON leaves fields empty", + httpMethod: http.MethodPost, + body: `not-json`, + wantMethod: "", + wantTool: "", + bodyInContext: true, + }, + { + name: "GET method is a no-op (call pointer unmodified)", + httpMethod: http.MethodGet, + body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"claim_card"}}`, + wantMethod: "", + wantTool: "", + bodyInContext: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + originalBody := tc.body + + // Build context: inject an MCPCall if the test scenario requires it. + ctx := t.Context() + + var call *ctxlog.MCPCall + if tc.bodyInContext { + ctx, call = ctxlog.WithMCPCall(ctx) + } + + // Downstream handler: reads r.Body and asserts it equals the original bytes. + downstream := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if r.Body != nil { + got, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, originalBody, string(got), + "downstream should see the original body bytes") + } + }) + + handler := mcpRequestInfoMiddleware(downstream) + + req := httptest.NewRequest(tc.httpMethod, "/mcp", strings.NewReader(tc.body)) + req = req.WithContext(ctx) + rw := httptest.NewRecorder() + + handler.ServeHTTP(rw, req) + + if tc.bodyInContext { + require.NotNil(t, call) + assert.Equal(t, tc.wantMethod, call.Method, "Method mismatch") + assert.Equal(t, tc.wantTool, call.Tool, "Tool mismatch") + } + }) + } +} + +// TestMCPRequestInfoMiddleware_nilContext verifies that a nil MCPCall in context +// (no /mcp route injection) is handled defensively — middleware delegates to next +// without panicking. +func TestMCPRequestInfoMiddleware_nilContext(t *testing.T) { + called := false + downstream := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + called = true + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, `{"method":"tools/call"}`, string(body)) + }) + + handler := mcpRequestInfoMiddleware(downstream) + + req := httptest.NewRequest(http.MethodPost, "/mcp", + bytes.NewBufferString(`{"method":"tools/call"}`)) + // No MCPCall injected into context. + + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + assert.True(t, called, "downstream should be called even when MCPCall is absent from context") +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 43c3c23c..e203b83c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -3,7 +3,10 @@ package mcp import ( + "bytes" "crypto/subtle" + "encoding/json" + "io" "log/slog" "net/http" "strings" @@ -11,6 +14,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mhersson/contextmatrix/internal/ctxlog" "github.com/mhersson/contextmatrix/internal/service" ) @@ -33,6 +37,14 @@ func NewServer(svc *service.CardService, workflowSkillsDir string) *mcp.Server { // NewHandler returns an http.Handler for MCP Streamable HTTP transport. // If apiKey is non-empty, requests must include a matching Authorization: Bearer header. // Register this on POST /mcp in the router. +// +// Middleware order (outermost → innermost): +// +// mcpAuthMiddleware → clearWriteDeadlineForStreaming → mcpRequestInfoMiddleware → SDK handler +// +// Unauthenticated probes are rejected before body inspection. The write-deadline +// tweak stays outermost to apply to all authenticated requests. Request info +// extraction runs just before the SDK so it can read the body once. func NewHandler(server *mcp.Server, apiKey string) http.Handler { handler := mcp.NewStreamableHTTPHandler( func(_ *http.Request) *mcp.Server { return server }, @@ -40,10 +52,10 @@ func NewHandler(server *mcp.Server, apiKey string) http.Handler { // headers (e.g. host.docker.internal) when running behind Docker Desktop. &mcp.StreamableHTTPOptions{DisableLocalhostProtection: true}, ) - // Wrap with write-deadline clearing for GET requests. The MCP Streamable HTTP - // transport uses a long-lived SSE stream on GET /mcp. Like the SSE endpoint, - // it must not be subject to the server's absolute WriteTimeout. - wrapped := clearWriteDeadlineForStreaming(handler) + // Innermost: SDK handler wrapped with request-info extraction. + infoWrapped := mcpRequestInfoMiddleware(handler) + // Middle: write-deadline clearing for long-lived GET SSE streams. + wrapped := clearWriteDeadlineForStreaming(infoWrapped) if apiKey == "" { return wrapped } @@ -51,6 +63,56 @@ func NewHandler(server *mcp.Server, apiKey string) http.Handler { return mcpAuthMiddleware(wrapped, apiKey) } +// mcpRequestInfoMiddleware reads the JSON-RPC body to populate the MCPCall +// stored in context by the outer observe middleware. It is best-effort: any +// read or parse error is swallowed so logging never breaks MCP traffic. +// +// The body is fully restored before calling next so the SDK handler receives +// the original bytes unchanged. +func mcpRequestInfoMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only POST carries a JSON-RPC body; GET/DELETE are SSE/session housekeeping. + if r.Method != http.MethodPost { + next.ServeHTTP(w, r) + + return + } + + call := ctxlog.MCPCallFromContext(r.Context()) + if call == nil { + // Defensive: observe only injects MCPCall for /mcp; if we somehow + // ended up mounted elsewhere, skip extraction rather than panic. + next.ServeHTTP(w, r) + + return + } + + // Read and restore the body so the SDK handler sees the original bytes. + if r.Body != nil { + buf, err := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(buf)) + + if err == nil { + // Minimal shape to extract method and (for tools/call) the tool name. + var msg struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + } `json:"params"` + } + if json.Unmarshal(buf, &msg) == nil { + call.Method = msg.Method + if msg.Method == "tools/call" { + call.Tool = msg.Params.Name + } + } + } + } + + next.ServeHTTP(w, r) + }) +} + // clearWriteDeadlineForStreaming wraps an http.Handler and disables the write // deadline for GET requests, which use long-lived SSE streaming. POST and DELETE // requests are short-lived and keep the normal write timeout. From 9c798af8ab51d3a4e031a58b5993516be4973bda Mon Sep 17 00:00:00 2001 From: ContextMatrix Runner Date: Tue, 12 May 2026 21:29:09 +0000 Subject: [PATCH 2/4] test(mcp): use assert.NoError in http handlers and tidy router whitespace - Replace require.NoError with assert.NoError inside httptest handler closures so testifylint go-require passes (require.NoError can call t.FailNow which is unsafe outside the test goroutine) - Apply whitespace-linter (wsl) formatting in observe middleware --- internal/api/router.go | 3 +++ internal/mcp/logging_test.go | 18 +++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 416f204c..9727700f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -313,8 +313,10 @@ func observe(next http.Handler) http.Handler { // the JSON-RPC body. We hold the pointer here so we can read it back // after ServeHTTP returns to append mcp_method/mcp_tool to the log line. var mcpCall *ctxlog.MCPCall + if r.URL.Path == "/mcp" { var ctx context.Context + ctx, mcpCall = ctxlog.WithMCPCall(r.Context()) r = r.WithContext(ctx) } @@ -338,6 +340,7 @@ func observe(next http.Handler) http.Handler { attrs = append(attrs, "mcp_tool", mcpCall.Tool) } } + ctxlog.Logger(r.Context()).Info("request", attrs...) // SSE streams would pollute the REST latency histogram and the diff --git a/internal/mcp/logging_test.go b/internal/mcp/logging_test.go index e8b39f35..5cc4ab17 100644 --- a/internal/mcp/logging_test.go +++ b/internal/mcp/logging_test.go @@ -19,12 +19,12 @@ import ( // MCPCall stored in context, while always restoring the body for downstream handlers. func TestMCPRequestInfoMiddleware(t *testing.T) { tests := []struct { - name string - httpMethod string - body string - wantMethod string - wantTool string - bodyInContext bool // whether to inject MCPCall into the context + name string + httpMethod string + body string + wantMethod string + wantTool string + bodyInContext bool // whether to inject MCPCall into the context }{ { name: "tools/call body populates method and tool", @@ -76,7 +76,7 @@ func TestMCPRequestInfoMiddleware(t *testing.T) { downstream := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { if r.Body != nil { got, err := io.ReadAll(r.Body) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, originalBody, string(got), "downstream should see the original body bytes") } @@ -108,8 +108,8 @@ func TestMCPRequestInfoMiddleware_nilContext(t *testing.T) { called = true body, err := io.ReadAll(r.Body) - require.NoError(t, err) - assert.Equal(t, `{"method":"tools/call"}`, string(body)) + assert.NoError(t, err) + assert.JSONEq(t, `{"method":"tools/call"}`, string(body)) }) handler := mcpRequestInfoMiddleware(downstream) From 45822c5e2ea1e6cac90848b3f5ca248f10261a60 Mon Sep 17 00:00:00 2001 From: ContextMatrix Runner Date: Tue, 12 May 2026 21:30:29 +0000 Subject: [PATCH 3/4] docs(mcp): document mcp_method/mcp_tool request log fields - Add gotcha entry explaining the two new slog fields emitted on POST /mcp requests (mcp_method, mcp_tool), the body-peeking middleware that extracts them, and best-effort semantics (errors swallowed, fields omitted when absent) - Update ctxlog component description in architecture.md to mention MCPCall context value and how observe middleware surfaces it on the log line --- docs/architecture.md | 6 +++++- docs/gotchas.md | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7b4ef68e..659404b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -181,7 +181,11 @@ and commit completion. The service layer closes that gap on failure: `internal/storage/`, and `internal/runner/` retrieve the logger via `ctxlog.Logger(ctx)` so every log line emitted during a request carries the same correlation ID. Falls back to `slog.Default()` for background contexts - that bypass the middleware (e.g. stall scanner goroutine). + that bypass the middleware (e.g. stall scanner goroutine). Also stores a + `*MCPCall` in the context (via `ctxlog.WithMCPCall`) for `/mcp` requests; + `mcpRequestInfoMiddleware` in `internal/mcp/server.go` populates it with + the JSON-RPC `method` and tool `name`, which the `observe` middleware then + appends as `mcp_method` / `mcp_tool` fields on the per-request log line. - **Metrics** (`metrics`): declares all Prometheus metric vars and exposes a `Register(prometheus.Registerer)` function called once at startup in `main.go`. Metrics are served at `GET /metrics` on the **admin listener** only diff --git a/docs/gotchas.md b/docs/gotchas.md index 45323440..d87c9226 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -116,6 +116,18 @@ not carry the correlation ID. Background goroutines (stall scanner, git-pull ticker) do not go through the middleware; `ctxlog.Logger(ctx)` falls back to `slog.Default()` safely in those paths. +- **MCP tool name in the request log line:** for `POST /mcp` requests the + `observe` middleware emits two extra fields alongside the standard `method`, + `path`, `status`, `duration_ms`, and `request_id` fields: + `mcp_method` (JSON-RPC method, e.g. `tools/call`) and `mcp_tool` (tool name, + e.g. `claim_card` or `report_usage`). Both fields are omitted for non-MCP + routes and for MCP methods other than `tools/call` (e.g. `initialize`) where + there is no tool name. The extraction is best-effort — a body-peeking + middleware (`mcpRequestInfoMiddleware` in `internal/mcp/server.go`) reads the + request body, parses the JSON-RPC envelope, restores the body, and writes the + results into a `*ctxlog.MCPCall` stashed in the context by `observe`. Errors + during extraction are swallowed; the log line is still emitted with whatever + fields were successfully extracted. - **`/metrics` and pprof live on the admin port:** Prometheus scraping (`GET /metrics`) and `/debug/pprof/*` are served only on the admin listener (`admin_port`), which defaults to `127.0.0.1` (`admin_bind_addr`). The main From 47a29f80aba8970d9c859bbd832d775d9eddfa92 Mon Sep 17 00:00:00 2001 From: ContextMatrix Runner Date: Tue, 12 May 2026 21:34:22 +0000 Subject: [PATCH 4/4] fix(mcp): cap mcp_method/mcp_tool log fields at 64 chars - Add truncateLogField helper and maxLogFieldLen constant in mcp/server.go - Truncate extracted JSON-RPC method and tool name before storing on the shared MCPCall so an authenticated client cannot inflate per-request slog lines with a multi-megabyte JSON-RPC payload (the body is already bounded at 5 MB by the outer bodyLimit middleware, but the extracted strings would otherwise be logged verbatim) - Add TestMCPRequestInfoMiddleware_truncatesLongFields covering both the long method-name and long tool-name paths --- internal/mcp/logging_test.go | 44 ++++++++++++++++++++++++++++++++++++ internal/mcp/server.go | 27 ++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/internal/mcp/logging_test.go b/internal/mcp/logging_test.go index 5cc4ab17..09b6b138 100644 --- a/internal/mcp/logging_test.go +++ b/internal/mcp/logging_test.go @@ -123,3 +123,47 @@ func TestMCPRequestInfoMiddleware_nilContext(t *testing.T) { assert.True(t, called, "downstream should be called even when MCPCall is absent from context") } + +// TestMCPRequestInfoMiddleware_truncatesLongFields verifies that an +// authenticated client cannot use a multi-megabyte JSON-RPC method or tool name +// to amplify the per-request slog line. Both fields are capped at maxLogFieldLen. +func TestMCPRequestInfoMiddleware_truncatesLongFields(t *testing.T) { + longMethod := strings.Repeat("M", maxLogFieldLen*4) + longTool := strings.Repeat("T", maxLogFieldLen*4) + + // Build a body where method == "tools/call" so the tool branch fires, but + // also include an oversize method via a separate body that uses the long + // method literally. + bodyToolsCall := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"` + longTool + `"}}` + bodyLongMethod := `{"jsonrpc":"2.0","method":"` + longMethod + `","params":{}}` + + t.Run("tools/call with long tool name", func(t *testing.T) { + ctx, call := ctxlog.WithMCPCall(t.Context()) + downstream := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler := mcpRequestInfoMiddleware(downstream) + + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(bodyToolsCall)) + req = req.WithContext(ctx) + + handler.ServeHTTP(httptest.NewRecorder(), req) + + assert.Equal(t, "tools/call", call.Method) + assert.LessOrEqual(t, len(call.Tool), maxLogFieldLen+len("…"), + "Tool should be truncated to at most maxLogFieldLen + ellipsis") + }) + + t.Run("long method name", func(t *testing.T) { + ctx, call := ctxlog.WithMCPCall(t.Context()) + downstream := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler := mcpRequestInfoMiddleware(downstream) + + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(bodyLongMethod)) + req = req.WithContext(ctx) + + handler.ServeHTTP(httptest.NewRecorder(), req) + + assert.LessOrEqual(t, len(call.Method), maxLogFieldLen+len("…"), + "Method should be truncated to at most maxLogFieldLen + ellipsis") + assert.Empty(t, call.Tool, "Tool should be empty for non-tools/call methods") + }) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e203b83c..a5a5b7f8 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -101,9 +101,16 @@ func mcpRequestInfoMiddleware(next http.Handler) http.Handler { } `json:"params"` } if json.Unmarshal(buf, &msg) == nil { - call.Method = msg.Method + // Cap field lengths so a malicious client cannot amplify + // each log line with a multi-megabyte method or tool name + // (the outer bodyLimit caps the body at 5 MB, but the + // extracted strings would otherwise be logged verbatim). + // Real MCP method names are <40 chars; 64 is a comfortable + // upper bound that matches the request_id ceiling used + // elsewhere in router.go. + call.Method = truncateLogField(msg.Method) if msg.Method == "tools/call" { - call.Tool = msg.Params.Name + call.Tool = truncateLogField(msg.Params.Name) } } } @@ -113,6 +120,22 @@ func mcpRequestInfoMiddleware(next http.Handler) http.Handler { }) } +// maxLogFieldLen bounds the mcp_method / mcp_tool values written to the request +// log line. Real MCP method names and tool names are <40 chars; 64 leaves room +// for future growth without giving an authenticated client a log-amplification +// primitive via a multi-megabyte JSON-RPC payload. +const maxLogFieldLen = 64 + +// truncateLogField clips s to maxLogFieldLen runes. Truncation suffix is added +// only when truncation actually happened so short values are unchanged. +func truncateLogField(s string) string { + if len(s) <= maxLogFieldLen { + return s + } + + return s[:maxLogFieldLen] + "…" +} + // clearWriteDeadlineForStreaming wraps an http.Handler and disables the write // deadline for GET requests, which use long-lived SSE streaming. POST and DELETE // requests are short-lived and keep the normal write timeout.