fix: propagate tenant_id through MCP bridge for memory indexing (#512)

Problem:
Memory documents saved via write_file → MemoryInterceptor are stored
in memory_documents (PutDocument succeeds via fail-open tenant fallback),
but IndexDocument fails with "tenant_id required" because the MCP bridge
chain never propagates tenant_id. This results in 0 memory_chunks,
making memory_search return empty for all agents using claude_cli provider.

Root cause:
- BridgeContext struct lacks TenantID field
- writeMCPConfigInternal never sets X-Tenant-ID header
- bridgeContextMiddleware never extracts/injects tenant_id into context
- PutDocument uses tenantIDForInsert() (fail-open, defaults to master)
- IndexDocument uses scopeClause() → requireTenantID() (fail-closed)

Fix:
- Add OptTenantID constant and BridgeContext.TenantID field
- Pass tenant_id from agent loop context → provider opts → MCP headers
- Add X-Tenant-ID to bridge request headers (HMAC-signed)
- Extract X-Tenant-ID in bridgeContextMiddleware → store.WithTenantID()
- Backward-compatible HMAC verification with 3 fallback levels
  (with tenant, without tenant, without workspace+tenant)

Affected: all agents using claude_cli provider type.
Also fixes: "KG embeddings backfill failed: tenant_id required"

Co-authored-by: admin <admin@admins-MacBook-Pro.local>
This commit is contained in:
mikedoandadmin authored and GitHub committed 2026-03-27 23:20:07 +07:00
1 parent f4f050bcd4
commit 6551c2d1e2
5 files changed
+35 -13

No files matched your search

+1
View File
@@ -519,6 +519,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
providers.OptChatID: req.ChatID,
providers.OptPeerKind: req.PeerKind,
providers.OptWorkspace: tools.ToolWorkspaceFromCtx(ctx),
providers.OptTenantID: store.TenantIDFromContext(ctx).String(),
},
}
if l.thinkingLevel != "" && l.thinkingLevel != "off" {
+7 -1
View File
@@ -225,8 +225,9 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle
}
// Verify HMAC signature over all context fields.
tenantIDStr := r.Header.Get("X-Tenant-ID")
sig := r.Header.Get("X-Bridge-Sig")
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, sig) {
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig) {
slog.Warn("security.mcp_bridge: invalid bridge context signature",
"agent_id", agentIDStr, "user_id", userID)
http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden)
@@ -241,6 +242,11 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
if tenantIDStr != "" {
if tid, err := uuid.Parse(tenantIDStr); err == nil {
ctx = store.WithTenantID(ctx, tid)
}
}
}
// Inject channel routing context for tools like message, cron, etc.
+4
View File
@@ -31,6 +31,10 @@ const OptPeerKind = "peer_kind"
// OptWorkspace passes the agent workspace path so MCP bridge tools can resolve file paths.
const OptWorkspace = "workspace"
// OptTenantID passes the tenant UUID string for per-session MCP config.
// Required for memory indexing and tenant-scoped queries via bridge tools.
const OptTenantID = "tenant_id"
// ClaudeCLIProvider implements Provider by shelling out to the `claude` CLI binary.
// It acts as a thin proxy: CLI manages session history, tool execution, and context.
// GoClaw only forwards the latest user message and streams back the response.
+22 -12
View File
@@ -90,6 +90,7 @@ type BridgeContext struct {
ChatID string
PeerKind string
Workspace string
TenantID string
}
// WriteMCPConfig writes a per-session MCP config file with agent context headers.
@@ -97,10 +98,10 @@ type BridgeContext struct {
// outside the agent's workDir so tokens are not exposed.
// Skips write if content is unchanged. Returns the file path.
func (d *MCPConfigData) WriteMCPConfig(ctx context.Context, sessionKey string, bc BridgeContext) string {
return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace)
return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID)
}
func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace string) string {
func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string {
if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) {
return ""
}
@@ -147,9 +148,12 @@ func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey,
if workspace != "" && !strings.ContainsAny(workspace, "\r\n\x00") {
headers["X-Workspace"] = workspace
}
if tenantID != "" && !strings.ContainsAny(tenantID, "\r\n\x00") {
headers["X-Tenant-ID"] = tenantID
}
// HMAC signature over all context fields to prevent header forgery
if d.GatewayToken != "" && (agentID != "" || userID != "") {
headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace)
headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID)
}
bridgeEntry := map[string]any{
@@ -252,22 +256,28 @@ func sanitizePathSegment(s string) string {
}
// SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery.
// Payload: agentID|userID|channel|chatID|peerKind|workspace
func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace string) string {
// Payload: agentID|userID|channel|chatID|peerKind|workspace|tenantID
func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace))
mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID))
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyBridgeContext checks the HMAC signature against the expected bridge context.
// Falls back to old format (without workspace) for backward compatibility with
// sessions whose MCP config was written before the workspace field was added.
func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, sig string) bool {
expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace)
// Falls back to old formats for backward compatibility with sessions whose MCP config
// was written before the workspace or tenantID fields were added.
func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string) bool {
// Current format: all fields including tenantID
expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID)
if hmac.Equal([]byte(expected), []byte(sig)) {
return true
}
// Fallback: verify old format (without workspace) for backward compat.
old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "")
// Fallback: without tenantID (pre-tenantID sessions)
noTenant := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, "")
if hmac.Equal([]byte(noTenant), []byte(sig)) {
return true
}
// Fallback: without workspace or tenantID (oldest sessions)
old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "", "")
return hmac.Equal([]byte(old), []byte(sig))
}
+1
View File
@@ -166,6 +166,7 @@ func bridgeContextFromOpts(opts map[string]any) BridgeContext {
ChatID: extractStringOpt(opts, OptChatID),
PeerKind: extractStringOpt(opts, OptPeerKind),
Workspace: extractStringOpt(opts, OptWorkspace),
TenantID: extractStringOpt(opts, OptTenantID),
}
}