Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
cfg.Translator,
github.FeatureFlags{LockdownMode: cfg.LockdownMode},
cfg.ContentWindowSize,
cfg.Logger,
)

// Inject dependencies into context for all tool handlers
Expand Down
14 changes: 14 additions & 0 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package github
import (
"context"
"errors"
"log/slog"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/observability"
obsvLog "github.com/github/github-mcp-server/pkg/observability/log"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -77,6 +80,9 @@ type ToolDependencies interface {

// GetContentWindowSize returns the content window size for log truncation
GetContentWindowSize() int

// GetLogger returns the logger
Logger(ctx context.Context) obsvLog.Logger
}

// BaseDeps is the standard implementation of ToolDependencies for the local server.
Expand All @@ -93,6 +99,7 @@ type BaseDeps struct {
T translations.TranslationHelperFunc
Flags FeatureFlags
ContentWindowSize int
Obsv observability.Exporters
}

// NewBaseDeps creates a BaseDeps with the provided clients and configuration.
Expand All @@ -104,6 +111,7 @@ func NewBaseDeps(
t translations.TranslationHelperFunc,
flags FeatureFlags,
contentWindowSize int,
logger *slog.Logger,
) *BaseDeps {
return &BaseDeps{
Client: client,
Expand All @@ -113,6 +121,7 @@ func NewBaseDeps(
T: t,
Flags: flags,
ContentWindowSize: contentWindowSize,
Obsv: observability.NewExporters(obsvLog.NewSlogLogger(logger, obsvLog.InfoLevel)),
}
}

Expand All @@ -134,6 +143,11 @@ func (d BaseDeps) GetRawClient(_ context.Context) (*raw.Client, error) {
// GetRepoAccessCache implements ToolDependencies.
func (d BaseDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return d.RepoAccessCache }

// GetLogger implements ToolDependencies.
func (d BaseDeps) Logger(ctx context.Context) obsvLog.Logger {
return d.Obsv.Logger(ctx)
}

// GetT implements ToolDependencies.
func (d BaseDeps) GetT() translations.TranslationHelperFunc { return d.T }

Expand Down
6 changes: 5 additions & 1 deletion pkg/github/dynamic_tools_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package github

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"testing"

"github.com/github/github-mcp-server/pkg/inventory"
Expand Down Expand Up @@ -128,12 +130,14 @@ func TestDynamicTools_EnableToolset(t *testing.T) {

// Create a mock server
server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuffer, nil))

// Create dynamic tool dependencies
deps := DynamicToolDependencies{
Server: server,
Inventory: reg,
ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0),
ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, logger),
T: translations.NullTranslationHelper,
}

Expand Down
10 changes: 10 additions & 0 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
Expand Down Expand Up @@ -1459,12 +1460,21 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError(err.Error()), nil, nil
}

deps.Logger(ctx).Info("list_issues called",
slog.String("owner", owner),
slog.String("repo", repo),
slog.String("orderBy", orderBy),
slog.String("direction", direction),
slog.String("since", since),
)

// There are two optional parameters: since and labels.
var sinceTime time.Time
var hasSince bool
if since != "" {
sinceTime, err = parseISOTimestamp(since)
if err != nil {
deps.Logger(ctx).Info("failed to parse 'since' timestamp", slog.String("error", err.Error()))
return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil
}
hasSince = true
Expand Down
4 changes: 4 additions & 0 deletions pkg/github/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"time"

"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/observability"
mcpLog "github.com/github/github-mcp-server/pkg/observability/log"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v79/github"
Expand All @@ -28,6 +30,7 @@ type stubDeps struct {
t translations.TranslationHelperFunc
flags FeatureFlags
contentWindowSize int
obsv observability.Exporters
}

func (s stubDeps) GetClient(ctx context.Context) (*github.Client, error) {
Expand Down Expand Up @@ -55,6 +58,7 @@ func (s stubDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return s.repo
func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t }
func (s stubDeps) GetFlags() FeatureFlags { return s.flags }
func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize }
func (s stubDeps) Logger(ctx context.Context) mcpLog.Logger { return s.obsv.Logger(ctx) }

// Helper functions to create stub client functions for error testing
func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*github.Client, error) {
Expand Down
28 changes: 28 additions & 0 deletions pkg/observability/log/level.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package log

type Level struct {
level string
}

var (
// DebugLevel causes all logs to be logged
DebugLevel = Level{"debug"}
// InfoLevel causes all logs of level info or more severe to be logged
InfoLevel = Level{"info"}
// WarnLevel causes all logs of level warn or more severe to be logged
WarnLevel = Level{"warn"}
// ErrorLevel causes all logs of level error or more severe to be logged
ErrorLevel = Level{"error"}
// FatalLevel causes only logs of level fatal to be logged
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment for FatalLevel states "causes only logs of level fatal to be logged" which is inconsistent with other level comments. This suggests filtering that would suppress all other logs, but FatalLevel should be described as "causes all logs of level fatal to be logged" or "causes only fatal severity logs to be logged" to maintain consistency with the pattern of the other levels.

Suggested change
// FatalLevel causes only logs of level fatal to be logged
// FatalLevel causes all logs of level fatal to be logged

Copilot uses AI. Check for mistakes.
FatalLevel = Level{"fatal"}
)

// String returns the string representation for Level
//
// This is useful when trying to get the string values for Level and mapping level in other external libraries. For example:
// ```
// trace.SetLogLevel(kvp.String("loglevel", log.DebugLevel.String())
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example code in the comment contains a syntax error. It shows kvp.String("loglevel", log.DebugLevel.String()) but should likely be demonstrating a valid function call.

Suggested change
// trace.SetLogLevel(kvp.String("loglevel", log.DebugLevel.String())
// trace.SetLogLevel(kvp.String("loglevel", log.DebugLevel.String()))

Copilot uses AI. Check for mistakes.
// ```
Comment on lines +23 to +25
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code example in the documentation is incomplete and contains a syntax error. The closing parenthesis is missing from the String() method call, and "kvp" is not defined in the example context. Consider revising to a complete, self-contained example.

Suggested change
// ```
// trace.SetLogLevel(kvp.String("loglevel", log.DebugLevel.String())
// ```
// ```go
// kvp := kv.String("loglevel", log.DebugLevel.String())
// trace.SetLogLevel(kvp)
// ```

Copilot uses AI. Check for mistakes.
func (l Level) String() string {
return l.level
}
21 changes: 21 additions & 0 deletions pkg/observability/log/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package log

import (
"context"
"log/slog"
)

type Logger interface {
Log(ctx context.Context, level Level, msg string, fields ...slog.Attr)
Debug(msg string, fields ...slog.Attr)
Info(msg string, fields ...slog.Attr)
Warn(msg string, fields ...slog.Attr)
Error(msg string, fields ...slog.Attr)
Fatal(msg string, fields ...slog.Attr)
WithFields(fields ...slog.Attr) Logger
WithError(err error) Logger
Named(name string) Logger
WithLevel(level Level) Logger
Sync() error
Level() Level
}
Comment on lines +8 to +21
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Logger interface and Exporters struct lack documentation. As part of a public API that will be used as a library by other repositories, these types should have clear documentation explaining their purpose and usage. Add godoc comments to describe what the Logger interface represents and what the Exporters struct is used for.

Copilot uses AI. Check for mistakes.
62 changes: 62 additions & 0 deletions pkg/observability/log/noop_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package log

import (
"context"
"log/slog"
)

type NoopLogger struct{}

var _ Logger = (*NoopLogger)(nil)

Comment on lines +7 to +11
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NoopLogger struct and NewNoopLogger function lack documentation. As part of a public API, add godoc comments explaining that NoopLogger is a logger implementation that discards all log messages, useful for testing or when logging is disabled.

Suggested change
type NoopLogger struct{}
var _ Logger = (*NoopLogger)(nil)
// NoopLogger is a Logger implementation that discards all log messages.
// It is useful in tests or when logging is intentionally disabled.
type NoopLogger struct{}
var _ Logger = (*NoopLogger)(nil)
// NewNoopLogger creates a NoopLogger that discards all log messages.
// This is helpful for tests or environments where logging should be turned off.

Copilot uses AI. Check for mistakes.
func NewNoopLogger() *NoopLogger {
return &NoopLogger{}
}

func (l *NoopLogger) Level() Level {
return DebugLevel
}

func (l *NoopLogger) Log(_ context.Context, _ Level, _ string, _ ...slog.Attr) {
// No-op
}

func (l *NoopLogger) Debug(_ string, _ ...slog.Attr) {
// No-op
}

func (l *NoopLogger) Info(_ string, _ ...slog.Attr) {
// No-op
}

func (l *NoopLogger) Warn(_ string, _ ...slog.Attr) {
// No-op
}

func (l *NoopLogger) Error(_ string, _ ...slog.Attr) {
// No-op
}

func (l *NoopLogger) Fatal(_ string, _ ...slog.Attr) {
// No-op
Comment on lines +40 to +41
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NoopLogger's Fatal method should maintain consistent behavior with the SlogLogger implementation. Since SlogLogger panics after logging a fatal message, NoopLogger should also panic to ensure consistent Fatal semantics across implementations. Otherwise, code that relies on Fatal terminating execution could continue running unexpectedly when using NoopLogger.

Suggested change
func (l *NoopLogger) Fatal(_ string, _ ...slog.Attr) {
// No-op
func (l *NoopLogger) Fatal(msg string, _ ...slog.Attr) {
panic("NoopLogger.Fatal called: " + msg)

Copilot uses AI. Check for mistakes.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your a badass ai

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your a badass ai

}

func (l *NoopLogger) WithFields(_ ...slog.Attr) Logger {
return l
}

func (l *NoopLogger) WithError(_ error) Logger {
return l
}

func (l *NoopLogger) Named(_ string) Logger {
return l
}

func (l *NoopLogger) WithLevel(_ Level) Logger {
return l
}

func (l *NoopLogger) Sync() error {
return nil
}
Comment on lines +12 to +62
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NoopLogger implementation lacks test coverage. While it's a simple no-op implementation, tests should verify that it correctly implements the Logger interface and that all methods are safe to call without side effects. Similar packages in this repository have corresponding test files.

Copilot uses AI. Check for mistakes.
108 changes: 108 additions & 0 deletions pkg/observability/log/slog_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package log

import (
"context"
"log/slog"
)

type SlogLogger struct {
logger *slog.Logger
level Level
}

func NewSlogLogger(logger *slog.Logger, level Level) *SlogLogger {
return &SlogLogger{
logger: logger,
level: level,
}
}
Comment on lines +8 to +18
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SlogLogger struct and NewSlogLogger function lack documentation. As part of a public API, these exported types should have clear godoc comments explaining their purpose. Add documentation describing that SlogLogger is an adapter that wraps Go's standard slog.Logger to implement the Logger interface.

Copilot uses AI. Check for mistakes.

func (l *SlogLogger) Level() Level {
return l.level
}

func (l *SlogLogger) Log(ctx context.Context, level Level, msg string, fields ...slog.Attr) {
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The level field in SlogLogger is stored but never used for filtering log messages. The Log method ignores l.level and passes all messages to the underlying slog.Logger regardless of their severity. This means the level parameter in NewSlogLogger and the Level() method serve no practical purpose. Either implement level filtering in the Log method to respect the configured level, or remove the unused level field if filtering is meant to be handled by the underlying slog.Logger configuration.

Suggested change
func (l *SlogLogger) Log(ctx context.Context, level Level, msg string, fields ...slog.Attr) {
func (l *SlogLogger) Log(ctx context.Context, level Level, msg string, fields ...slog.Attr) {
// Only log messages at or above the configured level
if level < l.level {
return
}

Copilot uses AI. Check for mistakes.
slogLevel := convertLevel(level)
l.logger.LogAttrs(ctx, slogLevel, msg, fields...)
}

func (l *SlogLogger) Debug(msg string, fields ...slog.Attr) {
l.Log(context.Background(), DebugLevel, msg, fields...)
}

func (l *SlogLogger) Info(msg string, fields ...slog.Attr) {
l.Log(context.Background(), InfoLevel, msg, fields...)
}

func (l *SlogLogger) Warn(msg string, fields ...slog.Attr) {
l.Log(context.Background(), WarnLevel, msg, fields...)
}

func (l *SlogLogger) Error(msg string, fields ...slog.Attr) {
l.Log(context.Background(), ErrorLevel, msg, fields...)
}

func (l *SlogLogger) Fatal(msg string, fields ...slog.Attr) {
l.Log(context.Background(), FatalLevel, msg, fields...)
// Sync to ensure the log message is flushed before panic
_ = l.Sync()
panic("fatal: " + msg)
}

func (l *SlogLogger) WithFields(fields ...slog.Attr) Logger {
fieldKvPairs := make([]any, 0, len(fields)*2)
for _, attr := range fields {
k, v := attr.Key, attr.Value
fieldKvPairs = append(fieldKvPairs, k, v.Any())
}
return &SlogLogger{
logger: l.logger.With(fieldKvPairs...),
level: l.level,
}
}

func (l *SlogLogger) WithError(err error) Logger {
if err == nil {
return l
}
return &SlogLogger{
logger: l.logger.With("error", err.Error()),
level: l.level,
}
}

func (l *SlogLogger) Named(name string) Logger {
return &SlogLogger{
logger: l.logger.With("logger", name),
level: l.level,
}
}

func (l *SlogLogger) WithLevel(level Level) Logger {
return &SlogLogger{
logger: l.logger,
level: level,
}
}

func (l *SlogLogger) Sync() error {
// Slog does not require syncing
return nil
}

func convertLevel(level Level) slog.Level {
switch level {
case DebugLevel:
return slog.LevelDebug
case InfoLevel:
return slog.LevelInfo
case WarnLevel:
return slog.LevelWarn
case ErrorLevel:
return slog.LevelError
case FatalLevel:
return slog.LevelError
default:
return slog.LevelInfo
}
}
Loading
Loading