Skip to content

Commit 9d3e8b7

Browse files
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>
1 parent 569a48d commit 9d3e8b7

File tree

11 files changed

+460
-11
lines changed

11 files changed

+460
-11
lines changed

cmd/github-mcp-server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ var (
9090
ExportTranslations: viper.GetBool("export-translations"),
9191
EnableCommandLogging: viper.GetBool("enable-command-logging"),
9292
LogFilePath: viper.GetString("log-file"),
93+
LogLevel: viper.GetString("log-level"),
9394
ContentWindowSize: viper.GetInt("content-window-size"),
9495
LockdownMode: viper.GetBool("lockdown-mode"),
9596
InsidersMode: viper.GetBool("insiders"),
@@ -137,6 +138,7 @@ var (
137138
ExportTranslations: viper.GetBool("export-translations"),
138139
EnableCommandLogging: viper.GetBool("enable-command-logging"),
139140
LogFilePath: viper.GetString("log-file"),
141+
LogLevel: viper.GetString("log-level"),
140142
ContentWindowSize: viper.GetInt("content-window-size"),
141143
LockdownMode: viper.GetBool("lockdown-mode"),
142144
RepoAccessCacheTTL: &ttl,
@@ -168,6 +170,7 @@ func init() {
168170
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
169171
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
170172
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
173+
rootCmd.PersistentFlags().String("log-level", "", "Log level (debug, info, warn, error). Defaults to debug when --log-file is set, info otherwise.")
171174
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
172175
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
173176
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
@@ -190,6 +193,7 @@ func init() {
190193
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
191194
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
192195
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
196+
_ = viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level"))
193197
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
194198
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
195199
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))

internal/ghmcp/server.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ type StdioServerConfig struct {
214214
// Path to the log file if not stderr
215215
LogFilePath string
216216

217+
// LogLevel overrides the default log level ("debug", "info", "warn", "error").
218+
// When empty, the default depends on LogFilePath: file-backed logs default
219+
// to Debug (local troubleshooting), stderr logs default to Info.
220+
LogLevel string
221+
217222
// Content window size
218223
ContentWindowSize int
219224

@@ -248,11 +253,14 @@ func RunStdioServer(cfg StdioServerConfig) error {
248253
return fmt.Errorf("failed to open log file: %w", err)
249254
}
250255
logOutput = file
251-
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
252256
} else {
253257
logOutput = os.Stderr
254-
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
255258
}
259+
level, err := observability.ParseLogLevel(cfg.LogLevel, defaultLogLevel(cfg.LogFilePath))
260+
if err != nil {
261+
return err
262+
}
263+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: level})
256264
logger := slog.New(slogHandler)
257265
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
258266

@@ -335,6 +343,16 @@ func RunStdioServer(cfg StdioServerConfig) error {
335343
return nil
336344
}
337345

346+
// defaultLogLevel returns the log level used when the user hasn't specified
347+
// one. Debug when a log file is configured (intended for local debugging),
348+
// Info when logs go to stderr (keeps terminal output quiet).
349+
func defaultLogLevel(logFilePath string) slog.Level {
350+
if logFilePath != "" {
351+
return slog.LevelDebug
352+
}
353+
return slog.LevelInfo
354+
}
355+
338356
// createFeatureChecker returns a FeatureFlagChecker that resolves features
339357
// using the centralized ResolveFeatureFlags function. For the local server,
340358
// features are resolved once at startup from --features CLI flag + insiders mode.

pkg/github/dependencies.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"log/slog"
88
"net/http"
9-
"os"
109

1110
ghcontext "github.com/github/github-mcp-server/pkg/context"
1211
"github.com/github/github-mcp-server/pkg/http/transport"
@@ -187,7 +186,15 @@ func (d BaseDeps) GetFlags(_ context.Context) FeatureFlags { return d.Flags }
187186
func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize }
188187

189188
// Logger implements ToolDependencies.
190-
func (d BaseDeps) Logger(_ context.Context) *slog.Logger {
189+
// If an enriched logger has been attached to ctx by ToolLoggingMiddleware
190+
// (via observability.ContextWithLogger), that logger is returned so tool
191+
// handlers inherit request-scoped attributes such as tool name and
192+
// mcp.method. Otherwise the base logger from the observability exporters
193+
// is returned.
194+
func (d BaseDeps) Logger(ctx context.Context) *slog.Logger {
195+
if l := observability.LoggerFromContext(ctx); l != nil {
196+
return l
197+
}
191198
return d.Obsv.Logger()
192199
}
193200

@@ -206,8 +213,12 @@ func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool {
206213

207214
enabled, err := d.featureChecker(ctx, flagName)
208215
if err != nil {
209-
// Log error but don't fail the tool - treat as disabled
210-
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err)
216+
// Treat errors as disabled, but surface them via the logger so
217+
// operators can diagnose feature-flag backend issues in production.
218+
d.Logger(ctx).Warn("feature flag check failed",
219+
slog.String("flag", flagName),
220+
slog.String("error", err.Error()),
221+
)
211222
return false
212223
}
213224

@@ -406,7 +417,11 @@ func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags {
406417
func (d *RequestDeps) GetContentWindowSize() int { return d.ContentWindowSize }
407418

408419
// Logger implements ToolDependencies.
409-
func (d *RequestDeps) Logger(_ context.Context) *slog.Logger {
420+
// See BaseDeps.Logger for the context-scoped logger fallback behaviour.
421+
func (d *RequestDeps) Logger(ctx context.Context) *slog.Logger {
422+
if l := observability.LoggerFromContext(ctx); l != nil {
423+
return l
424+
}
410425
return d.obsv.Logger()
411426
}
412427

@@ -423,8 +438,10 @@ func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) boo
423438

424439
enabled, err := d.featureChecker(ctx, flagName)
425440
if err != nil {
426-
// Log error but don't fail the tool - treat as disabled
427-
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err)
441+
d.Logger(ctx).Warn("feature flag check failed",
442+
slog.String("flag", flagName),
443+
slog.String("error", err.Error()),
444+
)
428445
return false
429446
}
430447

pkg/github/logging_middleware.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"time"
7+
8+
"github.com/github/github-mcp-server/pkg/observability"
9+
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
)
11+
12+
// MCPMethodCallTool is the JSON-RPC method name for MCP tool invocations.
13+
// The SDK keeps its equivalent constant unexported, so we mirror it here.
14+
const MCPMethodCallTool = "tools/call"
15+
16+
// ToolLoggingMiddleware returns an MCP middleware that uniformly logs every
17+
// tool invocation with its name, duration, and outcome, and exposes a
18+
// request-scoped *slog.Logger via observability.ContextWithLogger so tool
19+
// handlers can retrieve an enriched logger from deps.Logger(ctx).
20+
//
21+
// Logging policy:
22+
// - tools/call success: logged at Debug with tool name and duration.
23+
// - tools/call failure (error return or IsError result): logged at Error
24+
// with tool name, duration, and the error when present.
25+
// - Non-tool methods pass through without emitting any log line; the
26+
// middleware only attaches an enriched logger so downstream code can
27+
// still benefit from the method tag if it chooses to log.
28+
//
29+
// The base logger comes from ToolDependencies on the context (populated by
30+
// InjectDepsMiddleware), so this middleware must be registered AFTER
31+
// InjectDepsMiddleware in the receiving middleware chain.
32+
func ToolLoggingMiddleware() mcp.Middleware {
33+
return func(next mcp.MethodHandler) mcp.MethodHandler {
34+
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
35+
deps, ok := DepsFromContext(ctx)
36+
if !ok {
37+
// Deps not injected yet; nothing we can do but pass through.
38+
return next(ctx, method, req)
39+
}
40+
41+
base := deps.Logger(ctx)
42+
if base == nil {
43+
return next(ctx, method, req)
44+
}
45+
46+
logger := base.With(slog.String("mcp.method", method))
47+
toolName := toolNameFromRequest(method, req)
48+
if toolName != "" {
49+
logger = logger.With(slog.String("mcp.tool", toolName))
50+
}
51+
52+
ctx = observability.ContextWithLogger(ctx, logger)
53+
54+
// Only time+log for tool calls. Other methods (initialize,
55+
// resources/list, etc.) are infrastructure chatter we leave
56+
// to the SDK unless a handler chooses to log explicitly.
57+
if method != MCPMethodCallTool {
58+
return next(ctx, method, req)
59+
}
60+
61+
start := time.Now()
62+
result, err := next(ctx, method, req)
63+
duration := time.Since(start)
64+
65+
switch {
66+
case err != nil:
67+
logger.LogAttrs(ctx, slog.LevelError, "tool call failed",
68+
slog.Duration("duration", duration),
69+
slog.String("error", err.Error()),
70+
)
71+
case isErrorResult(result):
72+
logger.LogAttrs(ctx, slog.LevelError, "tool call returned error result",
73+
slog.Duration("duration", duration),
74+
)
75+
default:
76+
logger.LogAttrs(ctx, slog.LevelDebug, "tool call succeeded",
77+
slog.Duration("duration", duration),
78+
)
79+
}
80+
81+
return result, err
82+
}
83+
}
84+
}
85+
86+
// toolNameFromRequest extracts the tool name from a tools/call request.
87+
// Returns "" for other methods or when the name cannot be determined.
88+
func toolNameFromRequest(method string, req mcp.Request) string {
89+
if method != MCPMethodCallTool || req == nil {
90+
return ""
91+
}
92+
switch p := req.GetParams().(type) {
93+
case *mcp.CallToolParams:
94+
if p != nil {
95+
return p.Name
96+
}
97+
case *mcp.CallToolParamsRaw:
98+
if p != nil {
99+
return p.Name
100+
}
101+
}
102+
return ""
103+
}
104+
105+
// isErrorResult reports whether the MCP result represents a tool-reported
106+
// error (CallToolResult.IsError == true). A returned Go error is handled
107+
// separately by the caller.
108+
func isErrorResult(r mcp.Result) bool {
109+
if r == nil {
110+
return false
111+
}
112+
if ctr, ok := r.(*mcp.CallToolResult); ok && ctr != nil {
113+
return ctr.IsError
114+
}
115+
return false
116+
}

0 commit comments

Comments
 (0)