feat(mcp): support per-request dynamic headers from channel context

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 <noreply@anthropic.com>
This commit is contained in:
Andy Lo-A-Foe 2026-04-28 09:49:51 +02:00
parent 894c6251c5
commit a01c0ec3af
No known key found for this signature in database
GPG key ID: C0E4EB79E9E6A23D
5 changed files with 57 additions and 13 deletions

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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.
//

View file

@ -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)
}