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:
parent
894c6251c5
commit
a01c0ec3af
5 changed files with 57 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue