Skip to content

Commit 36f10f9

Browse files
michaelmelansoncursoragent
authored andcommitted
Add optional debug logging behind LOGGING=1 (#69)
* Add optional debug logging behind LOGGING=1. Log HTTP requests, MCP tool calls, auth header issues, and Render API errors to stderr when LOGGING=1 is set. Co-authored-by: Cursor <cursoragent@cursor.com> * Delegate Flush and Unwrap on HTTP logging wrapper. Streamable MCP uses http.Flusher for SSE; without delegation, LOGGING=1 broke GET /mcp with 500 and skipped flush on upgraded POST responses. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> GitOrigin-RevId: 0f053108388c01411dcdde3ab13513a7c4b842c4
1 parent 1b2ca9a commit 36f10f9

8 files changed

Lines changed: 171 additions & 9 deletions

File tree

cmd/server.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import (
77
"time"
88

99
"github.com/mark3labs/mcp-go/server"
10+
mcputil "github.com/mark3labs/mcp-go/util"
1011
"github.com/render-oss/render-mcp-server/pkg/authn"
1112
"github.com/render-oss/render-mcp-server/pkg/cfg"
1213
"github.com/render-oss/render-mcp-server/pkg/client"
1314
"github.com/render-oss/render-mcp-server/pkg/deploy"
1415
"github.com/render-oss/render-mcp-server/pkg/httpcontext"
1516
"github.com/render-oss/render-mcp-server/pkg/keyvalue"
17+
"github.com/render-oss/render-mcp-server/pkg/logging"
1618
"github.com/render-oss/render-mcp-server/pkg/logs"
1719
"github.com/render-oss/render-mcp-server/pkg/metrics"
1820
"github.com/render-oss/render-mcp-server/pkg/multicontext"
@@ -23,10 +25,16 @@ import (
2325
)
2426

2527
func Serve(transport string) *server.MCPServer {
28+
mcpServerOpts := []server.ServerOption{}
29+
if hooks := logging.NewHooks(); hooks != nil {
30+
mcpServerOpts = append(mcpServerOpts, server.WithHooks(hooks))
31+
}
32+
2633
// Create MCP server
2734
s := server.NewMCPServer(
2835
"render-mcp-server",
2936
cfg.Version,
37+
mcpServerOpts...,
3038
)
3139

3240
c, err := client.NewDefaultClient()
@@ -55,11 +63,14 @@ func Serve(transport string) *server.MCPServer {
5563
log.Print("using in-memory session store\n")
5664
sessionStore = session.NewInMemoryStore()
5765
}
58-
streamableServer := server.NewStreamableHTTPServer(s, server.WithHTTPContextFunc(multicontext.MultiHTTPContextFunc(
59-
session.ContextWithHTTPSession(sessionStore),
60-
authn.ContextWithAPITokenFromHeader,
61-
httpcontext.ContextWithHTTPRequest,
62-
)))
66+
streamableServer := server.NewStreamableHTTPServer(s,
67+
server.WithLogger(mcputil.DefaultLogger()),
68+
server.WithHTTPContextFunc(multicontext.MultiHTTPContextFunc(
69+
session.ContextWithHTTPSession(sessionStore),
70+
authn.ContextWithAPITokenFromHeader,
71+
httpcontext.ContextWithHTTPRequest,
72+
)),
73+
)
6374

6475
mux := http.NewServeMux()
6576
mux.Handle("/mcp", streamableServer)
@@ -72,7 +83,7 @@ func Serve(transport string) *server.MCPServer {
7283

7384
httpServer := &http.Server{
7485
Addr: ":10000",
75-
Handler: mux,
86+
Handler: logging.HTTPMiddleware(mux),
7687
ReadTimeout: 5 * time.Second,
7788
}
7889
err := httpServer.ListenAndServe()

pkg/authn/authn.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88

99
"github.com/render-oss/render-mcp-server/pkg/cfg"
10+
"github.com/render-oss/render-mcp-server/pkg/logging"
1011
)
1112

1213
const apiTokenKey string = "token"
@@ -28,6 +29,7 @@ func ContextWithAPITokenFromHeader(ctx context.Context, req *http.Request) conte
2829
token := req.Header.Get("Authorization")
2930

3031
if token == "" {
32+
logging.Error("auth: no Authorization header on %s %s", req.Method, req.URL.Path)
3133
return ctx
3234
}
3335

pkg/client/client.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/render-oss/render-mcp-server/pkg/cfg"
1313
"github.com/render-oss/render-mcp-server/pkg/config"
1414
"github.com/render-oss/render-mcp-server/pkg/httpcontext"
15+
"github.com/render-oss/render-mcp-server/pkg/logging"
1516
)
1617

1718
var ErrUnauthorized = errors.New("unauthorized")
@@ -42,17 +43,22 @@ func ErrorFromResponse(v any) error {
4243
}
4344

4445
if responseErr.Code == http.StatusUnauthorized {
46+
logging.Error("render api: unauthorized (status 401)")
4547
return ErrUnauthorized
4648
}
4749
if responseErr.Code == http.StatusForbidden {
50+
logging.Error("render api: forbidden (status 403)")
4851
return ErrForbidden
4952
}
5053

54+
var err error
5155
if responseErr.Message != nil && *responseErr.Message != "" {
52-
return fmt.Errorf("received response code %d: %s", responseErr.Code, *responseErr.Message)
56+
err = fmt.Errorf("received response code %d: %s", responseErr.Code, *responseErr.Message)
57+
} else {
58+
err = fmt.Errorf("received response code %d with empty message", responseErr.Code)
5359
}
54-
55-
return fmt.Errorf("unknown error")
60+
logging.Error("render api: %v", err)
61+
return err
5662
}
5763

5864
type ErrorWithCode struct {

pkg/client/client_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ func TestErrorFromResponse(t *testing.T) {
4545

4646
require.ErrorContains(t, err, "received response code 400: unknown error")
4747
})
48+
49+
t.Run("when body has no message field", func(t *testing.T) {
50+
err := client.ErrorFromResponse(&client.ListSnapshotsResponse{
51+
Body: []byte(`{}`),
52+
HTTPResponse: &http.Response{StatusCode: 502},
53+
})
54+
55+
require.ErrorContains(t, err, "received response code 502 with empty message")
56+
})
4857
})
4958

5059
t.Run("status code < 400", func(t *testing.T) {

pkg/logging/hooks.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package logging
2+
3+
import (
4+
"context"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
"github.com/mark3labs/mcp-go/server"
8+
)
9+
10+
func NewHooks() *server.Hooks {
11+
if !enabled() {
12+
return nil
13+
}
14+
15+
hooks := &server.Hooks{}
16+
17+
hooks.AddBeforeCallTool(func(_ context.Context, _ any, message *mcp.CallToolRequest) {
18+
Info("tool call start name=%s", message.Params.Name)
19+
})
20+
21+
hooks.AddAfterCallTool(func(_ context.Context, _ any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
22+
if result != nil && result.IsError {
23+
Error("tool call failed name=%s error=%s", message.Params.Name, toolResultText(result))
24+
return
25+
}
26+
Info("tool call ok name=%s", message.Params.Name)
27+
})
28+
29+
hooks.AddOnError(func(_ context.Context, _ any, method mcp.MCPMethod, _ any, err error) {
30+
Error("mcp error method=%s err=%v", method, err)
31+
})
32+
33+
return hooks
34+
}
35+
36+
func toolResultText(result *mcp.CallToolResult) string {
37+
for _, content := range result.Content {
38+
if text, ok := mcp.AsTextContent(content); ok && text.Text != "" {
39+
return text.Text
40+
}
41+
}
42+
return "unknown tool error"
43+
}

pkg/logging/httplog.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package logging
2+
3+
import (
4+
"net/http"
5+
"time"
6+
)
7+
8+
type statusResponseWriter struct {
9+
http.ResponseWriter
10+
status int
11+
}
12+
13+
func (w *statusResponseWriter) WriteHeader(status int) {
14+
w.status = status
15+
w.ResponseWriter.WriteHeader(status)
16+
}
17+
18+
func (w *statusResponseWriter) Flush() {
19+
if f, ok := w.ResponseWriter.(http.Flusher); ok {
20+
f.Flush()
21+
}
22+
}
23+
24+
func (w *statusResponseWriter) Unwrap() http.ResponseWriter {
25+
return w.ResponseWriter
26+
}
27+
28+
func HTTPMiddleware(next http.Handler) http.Handler {
29+
if !enabled() {
30+
return next
31+
}
32+
33+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+
start := time.Now()
35+
rw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
36+
next.ServeHTTP(rw, r)
37+
Info("http request method=%s path=%s status=%d duration=%s remote=%s",
38+
r.Method, r.URL.Path, rw.status, time.Since(start).Round(time.Millisecond), r.RemoteAddr)
39+
})
40+
}

pkg/logging/httplog_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package logging
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestHTTPMiddlewareDelegatesFlusher(t *testing.T) {
12+
t.Setenv("LOGGING", "1")
13+
14+
handler := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
15+
f, ok := w.(http.Flusher)
16+
require.True(t, ok, "wrapped ResponseWriter must implement http.Flusher")
17+
f.Flush()
18+
}))
19+
20+
rec := httptest.NewRecorder()
21+
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/mcp", nil))
22+
require.True(t, rec.Flushed)
23+
}

pkg/logging/log.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package logging
2+
3+
import (
4+
"log"
5+
"os"
6+
)
7+
8+
const envKey = "LOGGING"
9+
10+
var logger = log.New(os.Stderr, "render-mcp-server: ", log.LstdFlags)
11+
12+
func enabled() bool {
13+
return os.Getenv(envKey) == "1"
14+
}
15+
16+
func Info(format string, args ...any) {
17+
if !enabled() {
18+
return
19+
}
20+
logger.Printf("INFO "+format, args...)
21+
}
22+
23+
func Error(format string, args ...any) {
24+
if !enabled() {
25+
return
26+
}
27+
logger.Printf("ERROR "+format, args...)
28+
}

0 commit comments

Comments
 (0)