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/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,6 +40,27 @@ func outboundContextFromInbound(
|
||||||
return outboundCtx
|
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 {
|
func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope {
|
||||||
if scope == nil {
|
if scope == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,9 @@ toolLoop:
|
||||||
ts.sessionKey,
|
ts.sessionKey,
|
||||||
ts.opts.Dispatch.SessionScope,
|
ts.opts.Dispatch.SessionScope,
|
||||||
)
|
)
|
||||||
|
if inbound := ts.opts.Dispatch.InboundContext; inbound != nil {
|
||||||
|
execCtx = withMCPHeadersFromRaw(execCtx, inbound.Raw)
|
||||||
|
}
|
||||||
toolResult := ts.agent.Tools.ExecuteWithContext(
|
toolResult := ts.agent.Tools.ExecuteWithContext(
|
||||||
execCtx,
|
execCtx,
|
||||||
toolName,
|
toolName,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"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
|
// 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) {
|
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
// Clone the request to avoid modifying the original
|
|
||||||
req = req.Clone(req.Context())
|
req = req.Clone(req.Context())
|
||||||
|
|
||||||
// Add custom headers
|
|
||||||
for key, value := range t.headers {
|
for key, value := range t.headers {
|
||||||
req.Header.Set(key, value)
|
req.Header.Set(key, value)
|
||||||
}
|
}
|
||||||
|
if dynamic := toolshared.MCPHeaders(req.Context()); len(dynamic) > 0 {
|
||||||
// Use the base transport
|
for key, value := range dynamic {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
base := t.base
|
base := t.base
|
||||||
if base == nil {
|
if base == nil {
|
||||||
base = http.DefaultTransport
|
base = http.DefaultTransport
|
||||||
|
|
@ -381,15 +382,13 @@ func connectServer(
|
||||||
DisableStandaloneSSE: disableStandaloneSSE,
|
DisableStandaloneSSE: disableStandaloneSSE,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom headers if provided
|
|
||||||
if len(cfg.Headers) > 0 {
|
|
||||||
// Create a custom HTTP client with header-injecting transport
|
|
||||||
sseTransport.HTTPClient = &http.Client{
|
sseTransport.HTTPClient = &http.Client{
|
||||||
Transport: &headerTransport{
|
Transport: &headerTransport{
|
||||||
base: http.DefaultTransport,
|
base: http.DefaultTransport,
|
||||||
headers: cfg.Headers,
|
headers: cfg.Headers,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if len(cfg.Headers) > 0 {
|
||||||
logger.DebugCF("mcp", "Added custom HTTP headers",
|
logger.DebugCF("mcp", "Added custom HTTP headers",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"server": name,
|
"server": name,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ var (
|
||||||
ctxKeyAgentID = &toolCtxKey{"agentID"}
|
ctxKeyAgentID = &toolCtxKey{"agentID"}
|
||||||
ctxKeySessionKey = &toolCtxKey{"sessionKey"}
|
ctxKeySessionKey = &toolCtxKey{"sessionKey"}
|
||||||
ctxKeySessionScope = &toolCtxKey{"sessionScope"}
|
ctxKeySessionScope = &toolCtxKey{"sessionScope"}
|
||||||
|
ctxKeyMCPHeaders = &toolCtxKey{"mcpHeaders"}
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithToolContext returns a child context carrying channel and chatID.
|
// WithToolContext returns a child context carrying channel and chatID.
|
||||||
|
|
@ -130,6 +131,17 @@ func ToolSessionScope(ctx context.Context) *session.SessionScope {
|
||||||
return session.CloneScope(scope)
|
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.
|
// 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.
|
// 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)
|
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 {
|
func ToolChannel(ctx context.Context) string {
|
||||||
return toolshared.ToolChannel(ctx)
|
return toolshared.ToolChannel(ctx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue