Files
ad893908a5 fix(telegram): propagate local_key for forum topic routing in team notifications (#800)
* fix(telegram): propagate local_key for forum topic routing in team notifications

Team task status messages (dispatched, completed, progress) were always
delivered to the General topic in Telegram forum groups because the
notification pipeline had no access to the originating topic's local_key.

Root cause: wireTeamProgressNotifySubscriber in gateway_events.go published
OutboundMessage with no Metadata, so the Telegram adapter had no
message_thread_id to route to the correct forum topic.

Fix has two parts:

1. Team notify path (root cause):
   - Add LocalKey field to TeamTaskEventPayload (protocol)
   - Extract local_key from tool context in WithContextInfo()
   - Add LocalKey to NotifyRoutingMeta
   - Pass LocalKey through to OutboundMessage Metadata in both
     leader mode (InboundMessage) and direct mode (OutboundMessage)

2. MCP bridge context (supporting):
   - Propagate local_key and session_key through bridge HTTP headers
   - Add X-Local-Key and X-Session-Key to BridgeContext
   - Extract and inject into tool context in gateway middleware
   - Include in HMAC signature for integrity

Closes #798

* fix(telegram): pass LocalKey from task metadata in all dispatch/fail broadcast sites

The initial fix added LocalKey to the event payload and WithContextInfo(),
but 4 broadcast call sites use individual With* options instead of
WithContextInfo — so LocalKey was never populated for:

- fallback_dispatch (team_tasks_create.go)
- dispatch_unblocked (team_tool_dispatch.go)
- post_turn dispatch (team_tool_validation.go)
- blocker/fail (team_tasks_blocker.go)

Add WithLocalKey() option function and extract TaskMetaLocalKey from task
metadata at each site, matching the existing TaskMetaPeerKind pattern.

* test(mcp): add HMAC verification tests for extra params (localKey, sessionKey)

- Add tests for SignBridgeContext/VerifyBridgeContext with extra params
- Test backward compat fallback for pre-localKey sessions
- Test that param order matters in signature
- Add clarifying comment for routing context injection security model

---------

Co-authored-by: Jens Henke <jens@henke.dk>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-04-12 19:03:47 +07:00

182 lines
5.4 KiB
Go

package mcp
import (
"context"
"encoding/json"
"log/slog"
"mime"
"path/filepath"
"strings"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge.
// Excluded: spawn (agent loop), create_forum_topic (channels).
var BridgeToolNames = map[string]bool{
// Filesystem
"read_file": true,
"write_file": true,
"list_files": true,
"edit": true,
"exec": true,
// Web
"web_search": true,
"web_fetch": true,
// Memory & knowledge
"memory_search": true,
"memory_get": true,
"skill_search": true,
// Media
"read_image": true,
"create_image": true,
"tts": true,
// Browser automation
"browser": true,
// Scheduler
"cron": true,
// Messaging (send text/files to channels)
"message": true,
// Sessions (read + send)
"sessions_list": true,
"session_status": true,
"sessions_history": true,
"sessions_send": true,
// Team tools (context from X-Agent-ID/X-Channel/X-Chat-ID headers)
"team_tasks": true,
}
// NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools.
// It reads tools from the registry, filters to BridgeToolNames, and serves them
// over streamable-http transport (stateless mode).
// msgBus is optional; when non-nil, tools that produce media (deliver:true) will
// publish file attachments directly to the outbound bus.
func NewBridgeServer(reg *tools.Registry, version string, msgBus *bus.MessageBus) *mcpserver.StreamableHTTPServer {
srv := mcpserver.NewMCPServer("goclaw-bridge", version,
mcpserver.WithToolCapabilities(false),
)
// Register each safe tool from the GoClaw registry
var registered int
for name := range BridgeToolNames {
t, ok := reg.Get(name)
if !ok {
continue
}
mcpTool := convertToMCPTool(t)
handler := makeToolHandler(reg, name, msgBus)
srv.AddTool(mcpTool, handler)
registered++
}
slog.Info("mcp.bridge: tools registered", "count", registered)
return mcpserver.NewStreamableHTTPServer(srv,
mcpserver.WithStateLess(true),
)
}
// convertToMCPTool converts a GoClaw tools.Tool into an mcp-go Tool.
func convertToMCPTool(t tools.Tool) mcpgo.Tool {
schema, err := json.Marshal(t.Parameters())
if err != nil {
// Fallback: empty object schema
schema = []byte(`{"type":"object"}`)
}
return mcpgo.NewToolWithRawSchema(t.Name(), t.Description(), schema)
}
// makeToolHandler creates a ToolHandlerFunc that delegates to the GoClaw tool registry.
// When msgBus is non-nil and a tool result contains Media paths, the handler publishes
// them as outbound media attachments so files reach the user (e.g. Telegram document).
func makeToolHandler(reg *tools.Registry, toolName string, msgBus *bus.MessageBus) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
args := req.GetArguments()
// Pass routing context (channel, chatID, peerKind, sessionKey) so native
// tools can access local_key, session_key etc. for forum topic routing.
result := reg.ExecuteWithContext(ctx, toolName, args,
tools.ToolChannelFromCtx(ctx),
tools.ToolChatIDFromCtx(ctx),
tools.ToolPeerKindFromCtx(ctx),
tools.ToolSessionKeyFromCtx(ctx),
nil,
)
if result.IsError {
return mcpgo.NewToolResultError(result.ForLLM), nil
}
// Forward media files to the outbound bus so they reach the user as attachments.
// This is necessary because Claude CLI processes tool results internally —
// GoClaw's agent loop never sees result.Media from bridge tool calls.
forwardMediaToOutbound(ctx, msgBus, toolName, result)
return mcpgo.NewToolResultText(result.ForLLM), nil
}
}
// forwardMediaToOutbound publishes media files from a tool result to the outbound bus.
func forwardMediaToOutbound(ctx context.Context, msgBus *bus.MessageBus, toolName string, result *tools.Result) {
if msgBus == nil || len(result.Media) == 0 {
return
}
channel := tools.ToolChannelFromCtx(ctx)
chatID := tools.ToolChatIDFromCtx(ctx)
if channel == "" || chatID == "" {
slog.Debug("mcp.bridge: skipping media forward, missing channel context",
"tool", toolName, "channel", channel, "chat_id", chatID)
return
}
var attachments []bus.MediaAttachment
for _, mf := range result.Media {
ct := mf.MimeType
if ct == "" {
ct = mimeFromExt(filepath.Ext(mf.Path))
}
attachments = append(attachments, bus.MediaAttachment{
URL: mf.Path,
ContentType: ct,
})
}
peerKind := tools.ToolPeerKindFromCtx(ctx)
var meta map[string]string
if peerKind == "group" {
meta = map[string]string{"group_id": chatID}
}
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Media: attachments,
Metadata: meta,
})
slog.Debug("mcp.bridge: forwarded media to outbound bus",
"tool", toolName, "channel", channel, "files", len(attachments))
}
// mimeFromExt returns a MIME type for a file extension.
// Uses Go stdlib first, falls back to a small map for types not reliably
// handled by mime.TypeByExtension on all platforms (e.g. .opus, .webp).
func mimeFromExt(ext string) string {
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
switch strings.ToLower(ext) {
case ".webp":
return "image/webp"
case ".opus":
return "audio/ogg"
case ".md":
return "text/markdown"
default:
return "application/octet-stream"
}
}