From a01c0ec3afa18d087bf4b112f12b0fc4703e2a17 Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Tue, 28 Apr 2026 09:49:51 +0200 Subject: [PATCH] feat(mcp): support per-request dynamic headers from channel context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channels can now pass HTTP headers to MCP servers on a per-request basis by storing keys with a "mcp:" prefix in InboundContext.Raw. This enables forwarding bearer tokens from channel authentication to MCP tool calls. The context flows: Channel → InboundContext.Raw["mcp:Authorization"] → toolshared.MCPHeaders(ctx) → headerTransport.RoundTrip → HTTP header. SSE/HTTP transports now always use headerTransport (previously only when static headers were configured) so dynamic headers work out of the box. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/agent_utils.go | 22 ++++++++++++++++++++++ pkg/agent/pipeline_execute.go | 3 +++ pkg/mcp/manager.go | 25 ++++++++++++------------- pkg/tools/shared/base.go | 12 ++++++++++++ pkg/tools/shared_facade.go | 8 ++++++++ 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 9228b6d55..6d1b5e70f 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -39,6 +40,27 @@ func outboundContextFromInbound( return outboundCtx } +func withMCPHeadersFromRaw(ctx context.Context, raw map[string]string) context.Context { + if len(raw) == 0 { + return ctx + } + var headers map[string]string + for k, v := range raw { + after, ok := strings.CutPrefix(k, "mcp:") + if !ok || after == "" { + continue + } + if headers == nil { + headers = make(map[string]string) + } + headers[after] = v + } + if len(headers) == 0 { + return ctx + } + return tools.WithMCPHeaders(ctx, headers) +} + func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope { if scope == nil { return nil diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 0f71c7432..28b7c296d 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -436,6 +436,9 @@ toolLoop: ts.sessionKey, ts.opts.Dispatch.SessionScope, ) + if inbound := ts.opts.Dispatch.InboundContext; inbound != nil { + execCtx = withMCPHeadersFromRaw(execCtx, inbound.Raw) + } toolResult := ts.agent.Tools.ExecuteWithContext( execCtx, toolName, diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 958927767..ef51544f2 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -18,6 +18,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" ) // headerTransport is an http.RoundTripper that adds custom headers to requests @@ -45,15 +46,15 @@ func expandHomeCommandPath(command string) string { } func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Clone the request to avoid modifying the original req = req.Clone(req.Context()) - - // Add custom headers for key, value := range t.headers { req.Header.Set(key, value) } - - // Use the base transport + if dynamic := toolshared.MCPHeaders(req.Context()); len(dynamic) > 0 { + for key, value := range dynamic { + req.Header.Set(key, value) + } + } base := t.base if base == nil { base = http.DefaultTransport @@ -381,15 +382,13 @@ func connectServer( DisableStandaloneSSE: disableStandaloneSSE, } - // Add custom headers if provided + sseTransport.HTTPClient = &http.Client{ + Transport: &headerTransport{ + base: http.DefaultTransport, + headers: cfg.Headers, + }, + } if len(cfg.Headers) > 0 { - // Create a custom HTTP client with header-injecting transport - sseTransport.HTTPClient = &http.Client{ - Transport: &headerTransport{ - base: http.DefaultTransport, - headers: cfg.Headers, - }, - } logger.DebugCF("mcp", "Added custom HTTP headers", map[string]any{ "server": name, diff --git a/pkg/tools/shared/base.go b/pkg/tools/shared/base.go index 298e1b478..0b23c298a 100644 --- a/pkg/tools/shared/base.go +++ b/pkg/tools/shared/base.go @@ -50,6 +50,7 @@ var ( ctxKeyAgentID = &toolCtxKey{"agentID"} ctxKeySessionKey = &toolCtxKey{"sessionKey"} ctxKeySessionScope = &toolCtxKey{"sessionScope"} + ctxKeyMCPHeaders = &toolCtxKey{"mcpHeaders"} ) // WithToolContext returns a child context carrying channel and chatID. @@ -130,6 +131,17 @@ func ToolSessionScope(ctx context.Context) *session.SessionScope { return session.CloneScope(scope) } +// WithMCPHeaders returns a child context carrying per-request headers for MCP HTTP transports. +func WithMCPHeaders(ctx context.Context, headers map[string]string) context.Context { + return context.WithValue(ctx, ctxKeyMCPHeaders, headers) +} + +// MCPHeaders extracts per-request MCP headers from ctx, or nil if unset. +func MCPHeaders(ctx context.Context) map[string]string { + v, _ := ctx.Value(ctxKeyMCPHeaders).(map[string]string) + return v +} + // AsyncCallback is a function type that async tools use to notify completion. // When an async tool finishes its work, it calls this callback with the result. // diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go index 8409ea060..2a7e3f7fa 100644 --- a/pkg/tools/shared_facade.go +++ b/pkg/tools/shared_facade.go @@ -61,6 +61,14 @@ func WithToolSessionContext( return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) } +func WithMCPHeaders(ctx context.Context, headers map[string]string) context.Context { + return toolshared.WithMCPHeaders(ctx, headers) +} + +func MCPHeaders(ctx context.Context) map[string]string { + return toolshared.MCPHeaders(ctx) +} + func ToolChannel(ctx context.Context) string { return toolshared.ToolChannel(ctx) }