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
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>
117 lines
3.6 KiB
Go
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
|
|
}
|