From 6551c2d1e26e896b6f598e492baaa03fe7593d6c Mon Sep 17 00:00:00 2001 From: mikedo <74263697+ongchuthap@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:20:07 +0700 Subject: [PATCH] fix: propagate tenant_id through MCP bridge for memory indexing (#512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/agent/loop.go | 1 + internal/gateway/server.go | 8 +++++- internal/providers/claude_cli.go | 4 +++ internal/providers/claude_cli_mcp.go | 34 +++++++++++++++--------- internal/providers/claude_cli_session.go | 1 + 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 4de6735e..b62c9b50 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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" { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c7e48f5f..382ac0be 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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. diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go index 8b2f88e7..49849133 100644 --- a/internal/providers/claude_cli.go +++ b/internal/providers/claude_cli.go @@ -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. diff --git a/internal/providers/claude_cli_mcp.go b/internal/providers/claude_cli_mcp.go index b4fa6adb..dcdda2a9 100644 --- a/internal/providers/claude_cli_mcp.go +++ b/internal/providers/claude_cli_mcp.go @@ -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)) } diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go index 61f56e16..047e8718 100644 --- a/internal/providers/claude_cli_session.go +++ b/internal/providers/claude_cli_session.go @@ -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), } }