-
Notifications
You must be signed in to change notification settings - Fork 317
Expand file tree
/
Copy pathhandlers.go
More file actions
1808 lines (1680 loc) · 75.5 KB
/
Copy pathhandlers.go
File metadata and controls
1808 lines (1680 loc) · 75.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.
package mcpproxy
import (
"bufio"
"bytes"
"cmp"
"context"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/envoyproxy/ai-gateway/internal/filterapi"
"github.com/envoyproxy/ai-gateway/internal/internalapi"
"github.com/envoyproxy/ai-gateway/internal/json"
"github.com/envoyproxy/ai-gateway/internal/metrics"
"github.com/envoyproxy/ai-gateway/internal/tracing/tracingapi"
"github.com/envoyproxy/ai-gateway/internal/version"
)
var (
errSessionNotFound = errors.New("session not found")
errBackendNotFound = errors.New("backend not found")
errInvalidToolName = errors.New("invalid tool name")
errBackendResponseError = errors.New("one or more backends returned an error response")
)
// errToolCall represents a tool execution error with structured information
// about the failed tool call. This allows callers to have better context
// about tool execution failures.
type errToolCall struct {
toolName string
backend string
validationError bool
err error
}
func (e *errToolCall) Error() string {
return fmt.Sprintf("tool call failed: tool=%s, backend=%s: %v", e.toolName, e.backend, e.err)
}
func (e *errToolCall) Unwrap() error {
return e.err
}
// handlerResult contains metadata from single-backend handler execution.
// This struct is returned alongside error from handlers that target a specific backend,
// enabling centralized metrics recording with the correct backend context.
// The struct can be extended with additional fields as needed (e.g., custom metrics tags).
type handlerResult struct {
backendName string
}
// checkToolCallError examines a tools/call response and creates a structured error if isError is true.
// It extracts the tool name from the request params and the error content from the tool result.
// Returns nil if the response is not a tools/call error.
func checkToolCallError(req *jsonrpc.Request, msg *jsonrpc.Response, backendName string) *errToolCall {
if req == nil || req.Method != "tools/call" || msg.Result == nil {
return nil
}
var toolResult mcp.CallToolResult
if err := json.Unmarshal(msg.Result, &toolResult); err != nil || !toolResult.IsError {
return nil
}
// Extract tool name from request params
toolName := ""
if req.Params != nil {
var params mcp.CallToolParams
if err := json.Unmarshal(req.Params, ¶ms); err == nil {
toolName = params.Name
}
}
// Build error message from tool result content
var errMsg strings.Builder
var validationError bool
errMsg.WriteString("tool returned isError=true")
if len(toolResult.Content) > 0 {
errMsg.WriteString(": ")
for i, content := range toolResult.Content {
if i > 0 {
errMsg.WriteString("; ")
}
switch c := content.(type) { // extract text
case *mcp.TextContent:
errMsg.WriteString(c.Text)
// In this PR: https://github.com/modelcontextprotocol/go-sdk/pull/863
// The MCP Go SDK changed from returning a JSON-RPC error and error code indicating a validation error,
// to returning a successful JSON-RPC message and a tool result with IsError=true.
// The error details are just a string now and there is no indication of whether it's a validation error or not,
// so we do a best effort to guess, and fallback to an internal error.
if strings.Contains(c.Text, "validating") {
validationError = true
}
case *mcp.ImageContent:
errMsg.WriteString(fmt.Sprintf("[image: %s]", c.MIMEType))
default:
// For any other content type, try to marshal to JSON
if data, err := json.Marshal(content); err == nil {
errMsg.WriteString(string(data))
}
}
}
}
return &errToolCall{
toolName: toolName,
backend: backendName,
validationError: validationError,
err: errors.New(errMsg.String()),
}
}
func (m *mcpRequestContext) serveGET(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get(sessionIDHeader)
lastEventID := r.Header.Get(lastEventIDHeader)
if sessionID == "" {
m.l.Error("missing session ID in GET request")
http.Error(w, "missing session ID", http.StatusBadRequest)
return
}
s, err := m.sessionFromID(secureClientToGatewaySessionID(sessionID), secureClientToGatewayEventID(lastEventID))
if err != nil {
m.l.Error("invalid session ID in GET request", slog.String("session_id", sessionID), slog.String("error", err.Error()))
http.Error(w, fmt.Sprintf("invalid session ID: %v", err), http.StatusBadRequest)
return
}
if m.l.Enabled(r.Context(), slog.LevelDebug) {
m.l.Debug("Received MCP GET request",
slog.String("mcp_session_id", sessionID),
slog.String("last_event_id", lastEventID),
slog.Any("backend_session", s.perBackendSessions))
}
w.Header().Set(sessionIDHeader, sessionID)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("transfer-encoding", "chunked")
w.WriteHeader(http.StatusAccepted)
if err := s.streamNotifications(r.Context(), w, m.toolChangeSignaler); err != nil && !errors.Is(err, context.Canceled) {
m.l.Error("failed to collect notifications", slog.String("session_id", sessionID), slog.String("error", err.Error()))
http.Error(w, "failed to collect notifications", http.StatusInternalServerError)
return
}
}
func (m *mcpRequestContext) serverDELETE(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get(sessionIDHeader)
if sessionID == "" {
m.l.Error("missing session ID in DELETE request")
http.Error(w, "missing session ID", http.StatusBadRequest)
return
}
// we didn't care about last event id in DELETE.
s, err := m.sessionFromID(secureClientToGatewaySessionID(sessionID), "")
if err != nil {
m.l.Error("invalid session ID in DELETE request", slog.String("session_id", sessionID), slog.String("error", err.Error()))
http.Error(w, fmt.Sprintf("invalid session ID: %v", err), http.StatusBadRequest)
return
}
_ = s.Close() // Ignore error as it's not recoverable here. Errors per backend are logged in Close().
w.WriteHeader(http.StatusOK)
}
func onErrorResponse(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg))
}
// doNotForwardResponseToBackends checks whether the given response doesn't need to be forwarded to the backends.
// This is mostly because those are replies to Ping or ToolChange notifications initiated by hte gateway itself
// (not the backends).
func doNotForwardResponseToBackends(msg *jsonrpc.Response) bool {
str, ok := msg.ID.Raw().(string)
return ok && (strings.HasPrefix(str, envoyAIGatewayServerToClientPingRequestIDPrefix) ||
strings.HasPrefix(str, envoyAIGatewayServerToClientToolsChangedRequestIDPrefix))
}
func (m *mcpRequestContext) servePOST(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
startAt = time.Now()
s *session
err error
errType metrics.MCPErrorType
requestMethod string
span tracingapi.MCPSpan
params mcp.Params
applicationError bool
result handlerResult
)
defer func() {
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Completed MCP POST request",
slog.String("method", requestMethod),
slog.String("error_type", string(errType)),
slog.String("duration", time.Since(startAt).String()))
}
// Some request methods (e.g. notifications/initialized, tools/list) record per-backend
// metrics inside their own handlers, or don't involve backends at all. In those cases,
// perBackendMetricsRecorded is set to true and we skip the generic metrics recording
// below to avoid double-counting. We still need to close the tracing span.
if m.perBackendMetricsRecorded {
if span != nil {
if err != nil {
span.EndSpanOnError(string(errType), err)
} else {
span.EndSpan()
}
}
return
}
// Determine the metrics instance based on whether a backend was resolved.
metricsInstance := m.metrics
if result.backendName != "" {
metricsInstance = m.metrics.WithBackend(result.backendName)
}
applicationError = false
if err != nil {
var errToolCall *errToolCall
if errors.As(err, &errToolCall) {
applicationError = true
}
if span != nil {
span.EndSpanOnError(string(errType), err)
}
if applicationError {
metricsInstance.RecordMethodErrorCount(ctx, requestMethod, params, metrics.MCPStatusFailed)
} else {
metricsInstance.RecordMethodErrorCount(ctx, requestMethod, params, metrics.MCPStatusError)
}
metricsInstance.RecordRequestErrorDuration(ctx, startAt, errType, params)
return
}
if span != nil {
span.EndSpan()
}
metricsInstance.RecordRequestDuration(ctx, startAt, params)
// TODO: should we special case when this request is "Response" where method is empty?
metricsInstance.RecordMethodCount(ctx, requestMethod, params)
}()
if sessionID := r.Header.Get(sessionIDHeader); sessionID != "" {
s, err = m.sessionFromID(secureClientToGatewaySessionID(sessionID), secureClientToGatewayEventID(r.Header.Get(lastEventIDHeader)))
if err != nil {
errType = metrics.MCPErrorInvalidSessionID
m.l.Error("invalid session ID in POST request", slog.String("session_id", sessionID), slog.String("error", err.Error()))
http.Error(w, fmt.Sprintf("invalid session ID: %v", err), http.StatusBadRequest)
return
}
}
limit := m.maxRequestBodySize
if limit <= 0 {
limit = defaultMaxRequestBodySize
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
errType = metrics.MCPErrorInternal
onErrorResponse(w, http.StatusRequestEntityTooLarge, "request body too large")
return
}
errType = metrics.MCPErrorInternal
onErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
rawMsg, err := jsonrpc.DecodeMessage(body)
if err != nil {
errType = metrics.MCPErrorInvalidJSONRPC
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON-RPC message: %v", err))
return
}
switch msg := rawMsg.(type) {
case *jsonrpc.Response:
if doNotForwardResponseToBackends(msg) {
w.Header().Set(sessionIDHeader, string(s.clientGatewaySessionID()))
w.WriteHeader(http.StatusAccepted)
} else {
// We do require a Session ID. If it is not present, a 400 Bad Request response should be returned:
// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management
if s == nil {
errType = metrics.MCPErrorInvalidSessionID
onErrorResponse(w, http.StatusBadRequest, "missing session ID")
return
}
m.l.Debug("Decoded MCP response", slog.Any("response", msg))
result, err = m.handleClientToServerResponse(ctx, s, w, msg)
}
case *jsonrpc.Request:
requestMethod = msg.Method
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Decoded MCP request",
slog.Any("id", msg.ID), slog.String("method", msg.Method), slog.String("params", string(msg.Params)))
}
// We do require a Session ID. If it is not present for requests other than initialize,
// a 400 Bad Request response should be returned:
// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management
if s == nil && msg.Method != "initialize" {
errType = metrics.MCPErrorInvalidSessionID
onErrorResponse(w, http.StatusBadRequest, "missing session ID")
return
}
switch msg.Method {
case "notifications/roots/list_changed":
params = &mcp.RootsListChangedParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
err = m.handleNotificationsRootsListChanged(ctx, s, w, msg, params, span)
case "completion/complete":
params = &mcp.CompleteParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handleCompletionComplete(ctx, s, w, msg, params.(*mcp.CompleteParams), span)
case "notifications/progress":
params = &mcp.ProgressNotificationParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
m.metrics.RecordProgress(ctx, params)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handleClientToServerNotificationsProgress(ctx, s, w, msg, params.(*mcp.ProgressNotificationParams), span)
case "initialize":
// The very first request from the client to establish a session.
params = &mcp.InitializeParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
m.l.Error("Failed to unmarshal initialize params", slog.String("error", err.Error()))
onErrorResponse(w, http.StatusBadRequest, "invalid initialize params")
return
}
// The Envoy frontend listener’s HTTPRouteFilter adds the route name header.
// The MCP proxy uses it to locate the route’s backends and initialize sessions with them.
route := r.Header.Get(internalapi.MCPRouteHeader)
if route == "" {
errType = metrics.MCPErrorInternal
m.l.Error("cannot find route header in the downstream request")
onErrorResponse(w, http.StatusInternalServerError, "missing route header")
return
}
err = m.handleInitializeRequest(ctx, w, msg, params.(*mcp.InitializeParams), route, extractSubject(r), span, startAt)
case "notifications/initialized":
// According to the MCP spec, when the server receives a JSON-RPC response or notification from the client
// and accepts it, the server MUST return HTTP 202 Accepted with an empty body.
// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
// notifications/initialized is a client acknowledgement that doesn't get forwarded to any
// backend, so there are no per-backend metrics to record. Setting perBackendMetricsRecorded
// tells the deferred metrics block to skip the generic per-backend metric recording.
m.perBackendMetricsRecorded = true
w.WriteHeader(http.StatusAccepted)
return
case "logging/setLevel":
params = &mcp.SetLoggingLevelParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
m.l.Error("Failed to unmarshal set logging level params", slog.String("error", err.Error()))
onErrorResponse(w, http.StatusBadRequest, "invalid set logging level params")
return
}
err = m.handleSetLoggingLevel(ctx, s, w, msg, params.(*mcp.SetLoggingLevelParams), span)
case "ping":
// Ping is intentionally not traced as it's a lightweight health check.
err = m.handlePing(ctx, w, msg)
case "prompts/list":
params = &mcp.ListPromptsParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
err = m.handlePromptListRequest(ctx, s, w, msg, params.(*mcp.ListPromptsParams), span)
case "prompts/get":
params = &mcp.GetPromptParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handlePromptGetRequest(ctx, s, w, msg, params.(*mcp.GetPromptParams))
case "tools/call":
params = &mcp.CallToolParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
m.l.Error("Failed to unmarshal params", slog.String("method", msg.Method), slog.String("error", err.Error()))
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handleToolCallRequest(ctx, s, w, msg, params.(*mcp.CallToolParams), span, r)
case "tools/list":
params = &mcp.ListToolsParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
err = m.handleToolsListRequest(ctx, s, w, msg, params.(*mcp.ListToolsParams), span)
case "resources/list":
params = &mcp.ListResourcesParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
err = m.handleResourceListRequest(ctx, s, w, msg, params.(*mcp.ListResourcesParams), span)
case "resources/read":
params = &mcp.ReadResourceParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handleResourceReadRequest(ctx, s, w, msg, params.(*mcp.ReadResourceParams))
case "resources/templates/list":
params = &mcp.ListResourceTemplatesParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
err = m.handleResourcesTemplatesListRequest(ctx, s, w, msg, params.(*mcp.ListResourceTemplatesParams), span)
case "resources/subscribe":
params = &mcp.SubscribeParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handleResourcesSubscribeRequest(ctx, s, w, msg, params.(*mcp.SubscribeParams), span)
case "resources/unsubscribe":
params = &mcp.UnsubscribeParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
if err != nil {
errType = metrics.MCPErrorInvalidParam
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
result, err = m.handleResourcesUnsubscribeRequest(ctx, s, w, msg, params.(*mcp.UnsubscribeParams), span)
case "notifications/cancelled":
// The responsibility of cancelling the operation on server side is optional, so we just ignore it for now.
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
//
// TODO: If we want to do it properly, we need to maintain the request ID to backend mapping in a remote cache.
// According to the MCP spec, when the server receives a JSON-RPC response or notification from the client
// and accepts it, the server MUST return HTTP 202 Accepted with an empty body.
// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
w.WriteHeader(http.StatusAccepted)
default:
errType = metrics.MCPErrorUnsupportedMethod
err = fmt.Errorf("unsupported method: %s", msg.Method)
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("unsupported method: %s", msg.Method))
return
}
errType = errorType(err)
default:
errType = metrics.MCPErrorUnsupportedResponse
err = errors.New("unsupported JSON-RPC message type")
onErrorResponse(w, http.StatusBadRequest, "unsupported JSON-RPC message type")
}
}
func errorType(err error) metrics.MCPErrorType {
if err == nil {
return ""
}
// Check if error is a jsonrpc.Error and map the code to appropriate MCPErrorType
var jsonrpcErr *jsonrpc.Error
if errors.As(err, &jsonrpcErr) {
switch jsonrpcErr.Code {
case jsonrpc.CodeInvalidParams:
return metrics.MCPErrorInvalidParam
case jsonrpc.CodeMethodNotFound:
return metrics.MCPErrorUnsupportedMethod
case jsonrpc.CodeInvalidRequest, jsonrpc.CodeParseError:
return metrics.MCPErrorInvalidJSONRPC
case jsonrpc.CodeInternalError:
return metrics.MCPErrorInternal
default:
return metrics.MCPErrorInternal
}
}
// Check for specific error types
if errors.Is(err, errBackendNotFound) || errors.Is(err, errSessionNotFound) || errors.Is(err, errInvalidToolName) {
return metrics.MCPErrorInvalidParam
}
var toolCallValidaitonError *errToolCall
if errors.As(err, &toolCallValidaitonError) && toolCallValidaitonError.validationError {
return metrics.MCPErrorInvalidParam
}
// Check for joined errors last, as it's more expensive (recursive)
var joinedErrs interface{ Unwrap() []error }
if errors.As(err, &joinedErrs) {
errs := joinedErrs.Unwrap()
for _, e := range errs {
if errType := errorType(e); errType != "" && errType != metrics.MCPErrorInternal {
return errType
}
}
return metrics.MCPErrorInternal
}
return metrics.MCPErrorInternal
}
// handleInitializeRequest handles the "initialize" JSON-RPC method.
func (m *mcpRequestContext) handleInitializeRequest(ctx context.Context, w http.ResponseWriter, req *jsonrpc.Request, p *mcp.InitializeParams, route, subject string, span tracingapi.MCPSpan, startAt time.Time) error {
// Mark that per-backend metrics will be recorded to avoid duplicate recording in defer.
// This must be set early to handle any early returns that might occur.
// Note: mcp_request_duration and mcp_method_count are already recorded per-backend in initializeSession().
m.perBackendMetricsRecorded = true
m.metrics.RecordClientCapabilities(ctx, p.Capabilities, p)
s, err := m.newSession(ctx, p, route, subject, span, startAt)
if err != nil {
m.l.Error("failed to create new session", slog.String("error", err.Error()))
onErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to create new session: %v", err))
return err
}
result := mcp.InitializeResult{ProtocolVersion: protocolVersion20250618, ServerInfo: &mcp.Implementation{}}
result.ServerInfo.Name = "envoy-ai-gateway"
result.ServerInfo.Version = version.Parse()
result.Capabilities = s.mergedCapabilities()
marshal, err := json.Marshal(result)
if err != nil {
m.l.Error("failed to create new session", slog.String("error", err.Error()))
onErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to create new session: %v", err))
return err
}
// Convert it to raw JSON message.
data, err := jsonrpc.EncodeMessage(&jsonrpc.Response{
ID: req.ID,
Result: marshal,
})
if err != nil {
m.l.Error("failed to create new session", slog.String("error", err.Error()))
onErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to create new session: %v", err))
return err
}
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("MCP session initialized", slog.String("mcp_session_id", string(s.clientGatewaySessionID())))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(sessionIDHeader, string(s.clientGatewaySessionID()))
w.WriteHeader(http.StatusOK)
_, err = w.Write(data)
return err
}
// handleClientToServerResponse handles the response from client to server.
//
// The idea is that the request ID is constructed in maybeServerToClientRequestModify to include the original request ID, type, backend name and path prefix.
// So here we need to parse the ID and restore the original ID before sending it to the backend.
func (m *mcpRequestContext) handleClientToServerResponse(ctx context.Context, s *session, w http.ResponseWriter, res *jsonrpc.Response) (handlerResult, error) {
clientToServer, ok := res.ID.Raw().(string)
// We should've modified the server->client request ID to include the backend name.
if !ok {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid response ID type: %v", res.ID.Raw()))
return handlerResult{}, errors.New("invalid response ID type")
}
// TODO: we might want to encrypt/sign the ID to prevent tampering just like session in maybeServerToClientRequestModify.
// If we do that, we need to decrypt/verify it here.
parts := strings.Split(clientToServer, nameSeparator)
if len(parts) != 3 {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid response ID format: %s", clientToServer))
return handlerResult{}, errors.New("invalid response ID format")
}
originalIDRaw := parts[0]
typeIdentifier := parts[1]
backendName := parts[2]
result := handlerResult{backendName: backendName}
var id jsonrpc.ID
switch typeIdentifier {
case "i": // ID is an int64 encoded as bytes.
i64, err := strconv.ParseInt(originalIDRaw, 10, 64)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, "invalid response ID format")
return result, fmt.Errorf("invalid response ID format: %w", err)
}
id, err = jsonrpc.MakeID(float64(i64))
if err != nil {
onErrorResponse(w, http.StatusBadRequest, "invalid response ID format")
return result, fmt.Errorf("invalid response ID format: %w", err)
}
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Parsed int64 ID", slog.Int64("id", i64), slog.Any("jsonrpc_id", id))
}
case "f": // ID is a float64 encoded as bytes.
b, err := hex.DecodeString(originalIDRaw)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, "invalid response ID format")
return result, fmt.Errorf("invalid response ID format: %w: %s", err, originalIDRaw)
}
id, err = jsonrpc.MakeID(math.Float64frombits(binary.LittleEndian.Uint64(b)))
if err != nil {
onErrorResponse(w, http.StatusBadRequest, "invalid response ID format")
return result, fmt.Errorf("invalid response ID format: %w", err)
}
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Parsed float64 ID", slog.Float64("id", math.Float64frombits(binary.LittleEndian.Uint64(b))), slog.Any("jsonrpc_id", id))
}
case "s": // ID is a string encoded as base64.
decoded, err := base64.StdEncoding.DecodeString(originalIDRaw)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, "invalid response ID format")
return result, fmt.Errorf("invalid response ID format: %w: %s", err, originalIDRaw)
}
id, err = jsonrpc.MakeID(string(decoded))
if err != nil {
onErrorResponse(w, http.StatusBadRequest, "invalid response ID format")
return result, fmt.Errorf("invalid response ID format: %w", err)
}
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Parsed string ID", slog.String("id", originalIDRaw), slog.Any("jsonrpc_id", id))
}
default:
onErrorResponse(w, http.StatusBadRequest, "invalid response ID type identifier")
return result, fmt.Errorf("invalid response ID type identifier: %s", typeIdentifier)
}
res.ID = id
cse := s.getCompositeSessionEntry(backendName)
if cse == nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("no MCP session found for backend %s", backendName))
return result, fmt.Errorf("no MCP session found for backend %s", backendName)
}
backend, err := m.getBackendForRoute(s.route, backendName)
if err != nil {
onErrorResponse(w, http.StatusNotFound, fmt.Sprintf("unknown backend %s", backendName))
return result, fmt.Errorf("%w: unknown backend %s", errBackendNotFound, backendName)
}
resp, err := m.invokeJSONRPCRequest(ctx, s.route, backend, cse, res, nil)
if err != nil {
onErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to send: %v", err))
return result, err
}
defer func() {
ensureHTTPConnectionReused(resp)
}()
copyProxyHeaders(resp, w)
w.Header().Set(sessionIDHeader, string(s.clientGatewaySessionID()))
return result, m.proxyResponseBody(ctx, s, w, resp, nil, backend)
}
func (m *mcpRequestContext) handleToolCallRequest(ctx context.Context, s *session, w http.ResponseWriter, req *jsonrpc.Request, p *mcp.CallToolParams, span tracingapi.MCPSpan, r *http.Request) (handlerResult, error) {
backendName, toolName, err := upstreamResourceName(p.Name)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name %s: %v", p.Name, err))
return handlerResult{}, err
}
result := handlerResult{backendName: backendName}
backend, err := m.getBackendForRoute(s.route, backendName)
if err != nil {
onErrorResponse(w, http.StatusNotFound, fmt.Sprintf("unknown backend %s", backendName))
return result, fmt.Errorf("%w: unknown backend %s", errBackendNotFound, backendName)
}
// Validate that the tool is whitelisted for this route
route := m.routes[s.route]
if route == nil {
// This should never happen as the route must have been validated when the session is created.
onErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("route not found: %s", s.route))
return result, fmt.Errorf("route not found: %s", s.route)
}
selector := route.toolSelectors[backendName]
if selector != nil && !selector.allows(toolName) {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name: %s", toolName))
return result, fmt.Errorf("%w: %s", errInvalidToolName, toolName)
}
// Enforce authentication if required by the route.
if route.authorization != nil {
httpPath := ""
if r.URL != nil {
httpPath = r.URL.Path
}
allowed, requiredScopes := m.authorizeRequest(route.authorization, &authorizationRequest{
Headers: r.Header,
HTTPMethod: r.Method,
Host: r.Host,
HTTPPath: httpPath,
MCPMethod: req.Method,
Backend: backendName,
Tool: toolName,
Params: p,
})
if !allowed {
// Specify the minimum required scopes in the WWW-Authenticate header.
// Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
if len(requiredScopes) > 0 {
if challenge := buildInsufficientScopeHeader(requiredScopes, route.authorization.ResourceMetadataURL); challenge != "" {
w.Header().Set("WWW-Authenticate", challenge)
}
}
onErrorResponse(w, http.StatusForbidden, "access denied")
return result, fmt.Errorf("authorization failed")
}
}
cse := s.getCompositeSessionEntry(backendName)
if cse == nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("no MCP session found for backend %s", backendName))
return result, fmt.Errorf("%w: no MCP session found for backend %s", errSessionNotFound, backendName)
}
// Send the request to the MCP backend listener.
p.Name = toolName
param, _ := json.Marshal(p)
if m.l.Enabled(ctx, slog.LevelDebug) {
logger := m.l.With(slog.String("tool", p.Name), slog.Any("session", cse))
logger.Debug("Routing to backend")
}
if span != nil {
span.RecordRouteToBackend(backend.Name, string(cse.sessionID), false)
}
req.Params = param
return result, m.invokeAndProxyResponse(ctx, s, w, backend, cse, req, p)
}
func copyProxyHeaders(resp *http.Response, w http.ResponseWriter) {
isJSONResponse := resp.Header.Get("Content-Type") == "application/json"
for k, v := range resp.Header {
// Skip content-length header for non JSON response since we might modify the response.
if !isJSONResponse && strings.EqualFold(k, "content-length") {
continue
}
for _, vv := range v {
w.Header().Add(k, vv)
}
}
if !isJSONResponse {
w.Header().Set("Transfer-Encoding", "chunked")
}
}
// writeSingleJSONRPCMessage applies request/response modifications to a single decoded
// JSON-RPC message and writes it to w as a plain JSON body.
func (m *mcpRequestContext) writeSingleJSONRPCMessage(ctx context.Context, w http.ResponseWriter, statusCode int,
_msg jsonrpc.Message, body []byte, req *jsonrpc.Request, backend filterapi.MCPBackend,
) error {
var responseError error
switch msg := _msg.(type) {
case *jsonrpc.Request:
if err := m.maybeServerToClientRequestModify(ctx, msg, backend.Name); err != nil {
m.l.Error("failed to modify server->client request", slog.String("error", err.Error()))
return err
}
body, _ = jsonrpc.EncodeMessage(msg)
case *jsonrpc.Response:
if req != nil {
if err := m.maybeResponseModify(ctx, req, msg, backend.Name); err != nil {
m.l.Error("failed to modify response", slog.String("error", err.Error()))
return err
}
msg.ID = req.ID
// Check if this is a JSON-RPC error response
if msg.Error != nil {
responseError = msg.Error
} else if toolErr := checkToolCallError(req, msg, backend.Name); toolErr != nil {
// Check if this is a tools/call response with isError=true
responseError = toolErr
}
body, _ = jsonrpc.EncodeMessage(msg)
}
m.recordResponse(ctx, msg)
}
// We need to update the content length since we might have modified the ID.
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(statusCode)
_, _ = w.Write(body)
return responseError
}
// startsWithJSONObject reports whether the stream begins with '{', consuming only
// leading whitespace, which is insignificant to both SSE and JSON parsing.
func startsWithJSONObject(br *bufio.Reader) bool {
if buf, _ := br.Peek(len(utf8BOM)); bytes.Equal(buf, utf8BOM) {
_, _ = br.Discard(len(utf8BOM))
}
for {
b, err := br.ReadByte()
if err != nil {
return false
}
switch b {
case ' ', '\t', '\r', '\n':
continue
default:
_ = br.UnreadByte()
return b == '{'
}
}
}
func (m *mcpRequestContext) proxyResponseBody(ctx context.Context, s *session, w http.ResponseWriter, resp *http.Response,
req *jsonrpc.Request, backend filterapi.MCPBackend,
) error {
// Some backends (e.g. Slack MCP) send SSE data despite Content-Type: application/json.
// Try to decode as a single JSON-RPC message first; if that fails, fall through to the
// SSE parser using the already-read bytes.
var sseReader io.Reader
if resp.Header.Get("Content-Type") == "application/json" {
body, err := io.ReadAll(resp.Body)
if err != nil {
m.l.Error("failed to read response body", slog.String("error", err.Error()))
return err
}
_msg, ok := tryDecodeJSONRPCMessage(body)
if ok {
return m.writeSingleJSONRPCMessage(ctx, w, resp.StatusCode, _msg, body, req, backend)
}
// Body claimed application/json but isn't valid JSON-RPC (e.g. some backends
// send SSE data despite the content type). Fall through to the SSE parser
// using the already-read body bytes since resp.Body is now drained.
m.l.Info("response Content-Type is application/json but body is not valid JSON-RPC, falling back to SSE parsing",
slog.String("backend", backend.Name))
sseReader = bytes.NewReader(body)
} else {
// The mirror case: some backends send a plain JSON body despite Content-Type:
// text/event-stream. A valid SSE stream never starts with '{', so peek before
// handing the body to the SSE parser, which would silently drop the payload.
br := bufio.NewReader(resp.Body)
sseReader = br
if startsWithJSONObject(br) {
body, err := io.ReadAll(br)
if err != nil {
m.l.Error("failed to read response body", slog.String("error", err.Error()))
return err
}
if _msg, ok := tryDecodeJSONRPCMessage(body); ok {
m.l.Info("response Content-Type is text/event-stream but body is an unframed JSON-RPC message, handling as JSON",
slog.String("backend", backend.Name))
w.Header().Set("Content-Type", "application/json")
w.Header().Del("Transfer-Encoding")
return m.writeSingleJSONRPCMessage(ctx, w, resp.StatusCode, _msg, body, req, backend)
}
sseReader = bytes.NewReader(body)
}
}
// io.Copy won't flush until the end, which doesn't happen for streaming responses.
// So we need to read the body in chunks and flush after each chunk.
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Starting to stream MCP response body", slog.String("content_type", resp.Header.Get("Content-Type")), slog.String("mcp_session_id", resp.Header.Get(sessionIDHeader)))
}
w.WriteHeader(resp.StatusCode)
// For single-backend operations, metrics are recorded in the defer of servePOST,
// so we don't need to track startAt in events here.
parser := newSSEEventParser(sseReader, backend.Name)
// Collect errors from multiple events to return them all to the caller
var responseErrors []error
for {
event, err := parser.next()
// TODO: handle reconnect. We need to re-arrange the event ID so that it will also contain the backend name and the original session ID.
// Since event ID can be arbitrary string, we can shove each backend's last even ID into the event ID just like the session ID.
if event != nil {
// update per backend last event id then regenerate event id.
prev := event.id
s.setLastEventID(event.backend, event.id)
event.id = s.lastEventID()
if m.l.Enabled(ctx, slog.LevelDebug) {
m.l.Debug("Changed event ID", slog.String("backend", event.backend),
slog.String("prev_event_id", prev),
slog.String("event_id", event.id))
}
for _, _msg := range event.messages {
switch msg := _msg.(type) {
case *jsonrpc.Request:
if err = m.maybeServerToClientRequestModify(ctx, msg, backend.Name); err != nil {
m.l.Error("failed to modify server->client request", slog.String("error", err.Error()))
continue
}
case *jsonrpc.Response:
// Correct the ID to match the original request if possible.
if req != nil {
if err = m.maybeResponseModify(ctx, req, msg, backend.Name); err != nil {
m.l.Error("failed to modify response", slog.String("error", err.Error()))
continue
}
msg.ID = req.ID
// Check if this is a JSON-RPC error response
if msg.Error != nil {
// Collect error to return to caller
responseErrors = append(responseErrors, msg.Error)
} else if toolErr := checkToolCallError(req, msg, backend.Name); toolErr != nil {
// Check if this is a tools/call response with isError=true
responseErrors = append(responseErrors, toolErr)
}
}
m.recordResponse(ctx, msg)
}
}
event.writeAndMaybeFlush(w)
}
if err != nil {
if errors.Is(err, io.EOF) || strings.Contains(err.Error(), "context deadline exceeded") {
break
}
m.l.Error("failed to read MCP GET response body", slog.String("error", err.Error()))
break
}
}
return errors.Join(responseErrors...)
}
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress
const progressTokenMetadataKey = "progressToken"
func (m *mcpRequestContext) maybeUpdateProgressTokenMetadata(ctx context.Context, meta mcp.Meta, backendName filterapi.MCPBackendName) bool {
// TODO: maybe enctrypt/sign the progress token to prevent tampering just like session/event ID.
originalPt, ok := meta[progressTokenMetadataKey]
if !ok {
return false
}
var newPt string
switch v := originalPt.(type) {
case string:
base64Encoded := base64.StdEncoding.EncodeToString([]byte(v))
newPt = fmt.Sprintf("%s%ss%s%s", base64Encoded, nameSeparator, nameSeparator, backendName)
case int64:
newPt = fmt.Sprintf("%d%si%s%s", v, nameSeparator, nameSeparator, backendName)
case float64:
// Bytes encoded as number will be decoded as float64.
buf := [8]byte{}
b := buf[:]
binary.LittleEndian.PutUint64(b, math.Float64bits(v))
newPt = fmt.Sprintf("%x%sf%s%s", b, nameSeparator, nameSeparator, backendName)
case nil:
return false // Valid per spec.
default:
m.l.Warn("TODO/BUG: unsupported progressToken type in metadata", slog.String("type", fmt.Sprintf("%T", v)))
return false
}