-
Notifications
You must be signed in to change notification settings - Fork 2.1k
[mcp] mcputils for streamable http #58764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
9794788
2bbab9a
e366994
29816ea
566a8d5
9e30ee0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /* | ||
| * Teleport | ||
| * Copyright (C) 2025 Gravitational, Inc. | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU Affero General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU Affero General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU Affero General Public License | ||
| * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package mcputils | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "mime" | ||
| "net/http" | ||
|
|
||
| "github.com/gravitational/trace" | ||
| "github.com/mark3labs/mcp-go/mcp" | ||
|
|
||
| "github.com/gravitational/teleport" | ||
| "github.com/gravitational/teleport/lib/utils" | ||
| ) | ||
|
|
||
| // ServerMessageProcessor defines an interface that process JSON RPC responses | ||
| // and notifications. | ||
| type ServerMessageProcessor interface { | ||
| // ProcessResponse process a response and returns the message for client. | ||
| ProcessResponse(context.Context, *JSONRPCResponse) mcp.JSONRPCMessage | ||
| // ProcessNotification process a notification and returns the message for client. | ||
| ProcessNotification(context.Context, *JSONRPCNotification) mcp.JSONRPCMessage | ||
| } | ||
|
|
||
| // ReplaceHTTPResponse handles replacing the MCP server response for the | ||
| // streamable HTTP transport. | ||
| // | ||
| // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http | ||
| func ReplaceHTTPResponse(ctx context.Context, resp *http.Response, processer ServerMessageProcessor) error { | ||
| // Nothing to replace. | ||
| if resp.StatusCode != http.StatusOK || resp.ContentLength == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) | ||
| if err != nil { | ||
| return trace.Wrap(err) | ||
| } | ||
| switch mediaType { | ||
| case "application/json": | ||
| // Single response. | ||
| respBody, err := utils.ReadAtMost(resp.Body, teleport.MaxHTTPRequestSize) | ||
| if err != nil { | ||
| return trace.Wrap(err) | ||
| } | ||
| respFromServer, err := unmarshalResponse(string(respBody)) | ||
| if err != nil { | ||
| return trace.Wrap(err) | ||
| } | ||
| respToClient := processer.ProcessResponse(ctx, respFromServer) | ||
| respToClientAsBody, err := json.Marshal(respToClient) | ||
| if err != nil { | ||
| return trace.Wrap(err) | ||
| } | ||
| resp.Body = io.NopCloser(bytes.NewReader(respToClientAsBody)) | ||
| return nil | ||
|
|
||
| case "text/event-stream": | ||
| // Multiple messages (response or notification) can be sent through SSE. | ||
| // Instead of reading all messages then replacing them, here we replace | ||
| // the body with a reader that process the event one a time. | ||
| resp.Body = &httpSSEResponseReplacer{ | ||
| ctx: ctx, | ||
| SSEResponseReader: NewSSEResponseReader(resp.Body), | ||
| processer: processer, | ||
| } | ||
| return nil | ||
| default: | ||
| return trace.BadParameter("unsupported response type %s", mediaType) | ||
| } | ||
| } | ||
|
|
||
| type httpSSEResponseReplacer struct { | ||
| *SSEResponseReader | ||
| ctx context.Context | ||
| processer ServerMessageProcessor | ||
| buf []byte | ||
| } | ||
|
|
||
| func (r *httpSSEResponseReplacer) Read(p []byte) (int, error) { | ||
| if len(r.buf) != 0 { | ||
| n := copy(p, r.buf) | ||
| r.buf = r.buf[n:] | ||
| return n, nil | ||
| } | ||
|
|
||
| msg, err := r.ReadMessage(r.ctx) | ||
| if err != nil { | ||
| if utils.IsOKNetworkError(err) { | ||
| return 0, io.EOF | ||
| } | ||
| return 0, trace.Wrap(err) | ||
| } | ||
|
|
||
| var base BaseJSONRPCMessage | ||
| if err := json.Unmarshal([]byte(msg), &base); err != nil { | ||
| return 0, trace.Wrap(err) | ||
| } | ||
|
|
||
| var respToClient mcp.JSONRPCMessage | ||
| switch { | ||
| case base.IsResponse(): | ||
| respToClient = r.processer.ProcessResponse(r.ctx, base.MakeResponse()) | ||
| case base.IsNotification(): | ||
| respToClient = r.processer.ProcessNotification(r.ctx, base.MakeNotification()) | ||
| default: | ||
| return 0, trace.BadParameter("message is not a response or a notification") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to update
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no. it should be an error case which should fail the reading. according to mcp spec, this should not happen though, if server is implemented correctly. |
||
| } | ||
|
|
||
| respToSendAsBody, err := json.Marshal(respToClient) | ||
| if err != nil { | ||
| return 0, trace.Wrap(err) | ||
| } | ||
|
|
||
| // Convert to SSE. | ||
| r.buf = fmt.Appendf(r.buf, "event: message\ndata: %s\n\n", string(respToSendAsBody)) | ||
|
greedy52 marked this conversation as resolved.
Outdated
|
||
| return r.Read(p) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| /* | ||
| * Teleport | ||
| * Copyright (C) 2025 Gravitational, Inc. | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU Affero General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU Affero General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU Affero General Public License | ||
| * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package mcputils | ||
|
|
||
| import ( | ||
| "context" | ||
| "maps" | ||
| "net/http" | ||
| "sync" | ||
| "sync/atomic" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/gravitational/trace" | ||
| mcpclient "github.com/mark3labs/mcp-go/client" | ||
| mcpclienttransport "github.com/mark3labs/mcp-go/client/transport" | ||
| "github.com/mark3labs/mcp-go/mcp" | ||
| mcpserver "github.com/mark3labs/mcp-go/server" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/gravitational/teleport/lib/utils/mcptest" | ||
| ) | ||
|
|
||
| func TestReplaceHTTPResponse(t *testing.T) { | ||
| t.Parallel() | ||
| ctx := t.Context() | ||
|
|
||
| // Set up a server. | ||
| mcpServer := mcptest.NewServer() | ||
| httpServer := mcpserver.NewTestStreamableHTTPServer(mcpServer) | ||
| t.Cleanup(httpServer.Close) | ||
|
|
||
| // Set up a client with custom transport which calls "ReplaceHTTPResponse". | ||
| httpClientTransport := newTestReplaceHTTPResponseTransport() | ||
| mcpClientTransport, err := mcpclienttransport.NewStreamableHTTP( | ||
| httpServer.URL+"/mcp", | ||
| mcpclienttransport.WithHTTPBasicClient( | ||
| &http.Client{Transport: httpClientTransport}, | ||
| ), | ||
| mcpclienttransport.WithContinuousListening(), | ||
| ) | ||
| require.NoError(t, err) | ||
| client := mcpclient.NewClient(mcpClientTransport) | ||
| require.NoError(t, client.Start(ctx)) | ||
|
|
||
| // Initialize client and call a tool. | ||
| _, err = mcptest.InitializeClient(ctx, client) | ||
| require.NoError(t, err) | ||
| mcptest.MustCallServerTool(t, ctx, client) | ||
| assert.Equal(t, uint32(2), httpClientTransport.countMCPResponse.Load()) | ||
|
|
||
| // Send notifications from server. Notifications will be sent through SSE. | ||
| require.EventuallyWithT(t, func(collect *assert.CollectT) { | ||
| assert.Greater(collect, httpClientTransport.getCountMethods()["GET"], 0) | ||
| }, 2*time.Second, 100*time.Millisecond, "client SSE connected") | ||
| mcpServer.SendNotificationToAllClients("notifications/test", nil) | ||
| mcpServer.SendNotificationToAllClients("notifications/test", nil) | ||
| require.EventuallyWithT(t, func(collect *assert.CollectT) { | ||
| assert.Equal(collect, uint32(2), httpClientTransport.countMCPNotification.Load()) | ||
| }, 2*time.Second, 100*time.Millisecond, "expected to receive notification") | ||
|
|
||
| // Close client and count the requests. | ||
| require.NoError(t, client.Close()) | ||
| require.Equal(t, map[string]int{ | ||
| "GET": 1, // For listening on SSE events. | ||
| "POST": 3, // "initialize", "notifications/initialize", and "tools/call". | ||
| "DELETE": 1, // Close session. | ||
| }, httpClientTransport.getCountMethods()) | ||
| } | ||
|
|
||
| type testReplaceHTTPResponseTransport struct { | ||
| countMethods map[string]int | ||
| countMethodsMu sync.Mutex | ||
| countMCPResponse atomic.Uint32 | ||
| countMCPNotification atomic.Uint32 | ||
| } | ||
|
|
||
| func newTestReplaceHTTPResponseTransport() *testReplaceHTTPResponseTransport { | ||
| return &testReplaceHTTPResponseTransport{ | ||
| countMethods: make(map[string]int), | ||
| } | ||
| } | ||
|
|
||
| func (t *testReplaceHTTPResponseTransport) getCountMethods() map[string]int { | ||
| t.countMethodsMu.Lock() | ||
| defer t.countMethodsMu.Unlock() | ||
| return maps.Clone(t.countMethods) | ||
| } | ||
|
|
||
| func (t *testReplaceHTTPResponseTransport) RoundTrip(r *http.Request) (*http.Response, error) { | ||
| t.countMethodsMu.Lock() | ||
| t.countMethods[r.Method]++ | ||
| t.countMethodsMu.Unlock() | ||
|
|
||
| resp, err := http.DefaultClient.Do(r) | ||
| if err != nil { | ||
| return nil, trace.Wrap(err) | ||
| } | ||
|
|
||
| if err := ReplaceHTTPResponse(r.Context(), resp, t); err != nil { | ||
| return nil, trace.Wrap(err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| func (t *testReplaceHTTPResponseTransport) ProcessResponse(_ context.Context, response *JSONRPCResponse) mcp.JSONRPCMessage { | ||
| t.countMCPResponse.Add(1) | ||
| return response | ||
| } | ||
|
|
||
| func (t *testReplaceHTTPResponseTransport) ProcessNotification(_ context.Context, notification *JSONRPCNotification) mcp.JSONRPCMessage { | ||
| t.countMCPNotification.Add(1) | ||
| return notification | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.