Files
github--github-mcp-server/pkg/github/logging_middleware.go
Matt Holloway 9d3e8b707d
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
Draft: tool invocation logging middleware
Adds a sensible logging integration on top of the (currently unused)
observability.Exporters scaffolding:

  * pkg/github/logging_middleware.go
    ToolLoggingMiddleware times every tools/call and logs at Debug on
    success / Error on failure (Go error or IsError result). It enriches
    a *slog.Logger with mcp.method and mcp.tool, then stores it on the
    context via observability.ContextWithLogger so tool handlers pick up
    the request-scoped logger automatically from deps.Logger(ctx).

  * pkg/observability/logger_context.go
    ContextWithLogger / LoggerFromContext helpers.

  * pkg/observability/log_level.go
    ParseLogLevel for 'debug|info|warn|error'.

  * --log-level flag / GITHUB_LOG_LEVEL env var
    Fills an obvious gap: previously levels were hard-coded (stderr=Info,
    file=Debug). The default behaviour is preserved when the flag is empty.

  * Fix fmt.Fprintf(os.Stderr, ...) feature-flag check reporting in
    BaseDeps.IsFeatureEnabled / RequestDeps.IsFeatureEnabled — these
    previously bypassed the structured logger entirely.

Tool handler code is intentionally untouched. The middleware gives every
tool a duration/outcome log line uniformly; tools that want richer
structured logs can opt in via deps.Logger(ctx).

Tests cover the middleware (success / error / IsError / non-tool
pass-through / missing deps), the context helpers, level parsing, and
the BaseDeps.Logger(ctx) context fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:18:21 +01:00

117 lines
3.6 KiB
Go

package github
import (
"context"
"log/slog"
"time"
"github.com/github/github-mcp-server/pkg/observability"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPMethodCallTool is the JSON-RPC method name for MCP tool invocations.
// The SDK keeps its equivalent constant unexported, so we mirror it here.
const MCPMethodCallTool = "tools/call"
// ToolLoggingMiddleware returns an MCP middleware that uniformly logs every
// tool invocation with its name, duration, and outcome, and exposes a
// request-scoped *slog.Logger via observability.ContextWithLogger so tool
// handlers can retrieve an enriched logger from deps.Logger(ctx).
//
// Logging policy:
// - tools/call success: logged at Debug with tool name and duration.
// - tools/call failure (error return or IsError result): logged at Error
// with tool name, duration, and the error when present.
// - Non-tool methods pass through without emitting any log line; the
// middleware only attaches an enriched logger so downstream code can
// still benefit from the method tag if it chooses to log.
//
// The base logger comes from ToolDependencies on the context (populated by
// InjectDepsMiddleware), so this middleware must be registered AFTER
// InjectDepsMiddleware in the receiving middleware chain.
func ToolLoggingMiddleware() mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
deps, ok := DepsFromContext(ctx)
if !ok {
// Deps not injected yet; nothing we can do but pass through.
return next(ctx, method, req)
}
base := deps.Logger(ctx)
if base == nil {
return next(ctx, method, req)
}
logger := base.With(slog.String("mcp.method", method))
toolName := toolNameFromRequest(method, req)
if toolName != "" {
logger = logger.With(slog.String("mcp.tool", toolName))
}
ctx = observability.ContextWithLogger(ctx, logger)
// Only time+log for tool calls. Other methods (initialize,
// resources/list, etc.) are infrastructure chatter we leave
// to the SDK unless a handler chooses to log explicitly.
if method != MCPMethodCallTool {
return next(ctx, method, req)
}
start := time.Now()
result, err := next(ctx, method, req)
duration := time.Since(start)
switch {
case err != nil:
logger.LogAttrs(ctx, slog.LevelError, "tool call failed",
slog.Duration("duration", duration),
slog.String("error", err.Error()),
)
case isErrorResult(result):
logger.LogAttrs(ctx, slog.LevelError, "tool call returned error result",
slog.Duration("duration", duration),
)
default:
logger.LogAttrs(ctx, slog.LevelDebug, "tool call succeeded",
slog.Duration("duration", duration),
)
}
return result, err
}
}
}
// toolNameFromRequest extracts the tool name from a tools/call request.
// Returns "" for other methods or when the name cannot be determined.
func toolNameFromRequest(method string, req mcp.Request) string {
if method != MCPMethodCallTool || req == nil {
return ""
}
switch p := req.GetParams().(type) {
case *mcp.CallToolParams:
if p != nil {
return p.Name
}
case *mcp.CallToolParamsRaw:
if p != nil {
return p.Name
}
}
return ""
}
// isErrorResult reports whether the MCP result represents a tool-reported
// error (CallToolResult.IsError == true). A returned Go error is handled
// separately by the caller.
func isErrorResult(r mcp.Result) bool {
if r == nil {
return false
}
if ctr, ok := r.(*mcp.CallToolResult); ok && ctr != nil {
return ctr.IsError
}
return false
}