How to propagate the headers received by a Server to a Client
Hello everyone. I’m developing a gateway for the company where I work, in which we will centralize several security rules, auditing, etc., so that this does not need to be replicated in different MCP applications. In this way, the gateway will have multiple servers and clients started, one for each destination.
The difficulty I’m having is using the headers received by a server at the moment of executing a client’s tool. Each path of my gateway points to a different Server and Client, so when the Server receives a request, it applies the validations and uses the client to call the tool, but because everything was started at startup, the request sent to the destination MCP will lack the necessary headers. Only the parameters are propagated, but I can’t pass the headers.
I’m trying something like this, but I’ve already tried using middleware and other approaches.
client := mcp.NewClient(&mcp.Implementation{Name: "A"}, &mcp.ClientOptions{})
clientSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{....}, nil)
server := mcp.NewServer(&mcp.Implementation{...}, nil)
tools := clientSession.Tools(ctx, nil)
for tool, _ := range tools {
server.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*.mcp.CallToolResult, error) {
// GET HEADERS
return clientSession.CallTool(ctx, req.Params)
))
}
...
Is there a way to do this?
As of v0.3.0, you should be able to do:
func myToolHandler(ctx, req, in) (res, out, error) {
... req.Extra.Header ...
}
Let us know ASAP if that's not working for you.
It’s probably that I’m doing something wrong, because I looked at the details of the new version, but I didn’t quite understand how to implement it. I wrote the code below, and it didn’t work as expected. The destination server still isn’t receiving the headers. Could you help me @jba ?
func main() {
ctx := context.Background()
app, err := NewWebApplication()
if err != nil {
log.Fatal(err)
}
newClient := mcp.NewClient(&mcp.Implementation{Name: "my-destination-server"}, &mcp.ClientOptions{})
clientSession, _ := newClient.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: "http://0.0.0.0:8081/mcp", MaxRetries: 1}, nil)
server := mcp.NewServer(&mcp.Implementation{Name: "my-gateway", Version: "v1"}, nil)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
tools := clientSession.Tools(ctx, nil)
for tool, _ := range tools {
server.AddTool(mcp.ToolFor(tool, func(nCtx context.Context, req *mcp.CallToolRequest, in any) (*mcp.CallToolResult, any, error) {
fmt.Println(req.Extra.Header) // Headers already exist here
result, err := clientSession.CallTool(nCtx , req.Params)
return result, nil, err
}))
}
app.Any("/os", Handler(handler))
err = app.Run()
if err != nil {
log.Fatal(err)
}
}
func Handler(handler *mcp.StreamableHTTPHandler) web.Handler {
return func(w http.ResponseWriter, r *http.Request) error {
handler.ServeHTTP(w, r)
return nil
}
}
It seems to me that although version 0.3.0 changed the objects from CallToolParams to CallToolRequest in the ToolHandler, the CallTool function on the client remains the same and still receives the parameters, so I can’t propagate them in my specific gateway scenario. Is that correct?
@aleksousa can you do this with the StreamableClientTransport's HTTPClient field?
@findleyr I solved it by creating a RoundTrip implementation and passing it to the HttpClient of StreamableClientTransport in the client. Inside my ToolHandler I add the headers to the context, and in the RoundTrip function I attach them to the new request. Like this.
....
clientSession, _ := newClient.Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: "http://0.0.0.0:8081/mcp",
MaxRetries: 1,
HTTPClient: NewHeaderTrip()}, nil)
tools := clientSession.Tools(context.Background(), nil)
for tool, _ := range tools {
server.AddTool(mcp.ToolFor(tool, func(ctx context.Context, req *mcp.CallToolRequest, in any) (*mcp.CallToolResult, any, error) {
newCtx := context.WithValue(ctx, "ctx-headers", req.Extra.Header)
result, err := clientSession.CallTool(newCtx, req.Params)
return result, nil, err
}))
}
....
type HeaderTrip struct {
base http.RoundTripper
}
func (h *HeaderTrip) RoundTrip(req *http.Request) (*http.Response, error) {
newReq := req.Clone(req.Context())
headers := req.Context().Value("ctx-headers")
if headers != nil {
for key, value := range headers.(http.Header) {
newReq.Header.Set(key, value[0])
}
}
return h.base.RoundTrip(newReq)
}
func NewHeaderTrip() *http.Client {
return &http.Client{
Transport: &HeaderTrip{
base: http.DefaultTransport,
},
}
}
It ended up as a simple implementation, but I wondered if there could be a way to pass those headers dynamically to the client at the moment CallTool is executed.
I wondered if there could be a way to pass those headers dynamically to the client at the moment CallTool is executed.
I could see how this could work, backward-compatibly. We could add an Extra field to CallToolParams, and plumb it through so that the streamable transport could add headers to the outgoing request.
However, I'm not convinced we should do it. It makes things too dependent on the type of transport. (You could also make the same argument for ServerRequest.Extra.Headers, I realize that.)
I think your solution is a decent one, at least for now.
Hi @jba @aleksousa, I've just opened a related issue, #513, that identifies a fundamental bug in how StreamableClientTransport handles context propagation.
I wanted to flag this here because the proposed workarounds in this thread that rely on passing request-scoped values via context will likely not work as expected for background operations managed by the transport. The root cause is that the transport currently discards the parent context and creates its own with context.Background().
Until the context propagation is fixed as described in #513, any solution that depends on context values being available during the SSE connection or the final DELETE request will probably fail. It might be worth prioritizing the fix for #513 to unblock this and other related issues.
Thanks!