diff --git a/workspace-server/internal/handlers/mcp_tools_memory_v2.go b/workspace-server/internal/handlers/mcp_tools_memory_v2.go index cd0bd4c5c..6dd022a48 100644 --- a/workspace-server/internal/handlers/mcp_tools_memory_v2.go +++ b/workspace-server/internal/handlers/mcp_tools_memory_v2.go @@ -48,6 +48,7 @@ type memoryV2Deps struct { // call. Defining an interface here lets handler tests stub the plugin // without spinning up an HTTP server. type memoryPluginAPI interface { + UpsertNamespace(ctx context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) CommitMemory(ctx context.Context, namespace string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) Search(ctx context.Context, body contract.SearchRequest) (*contract.SearchResponse, error) ForgetMemory(ctx context.Context, id string, body contract.ForgetRequest) error @@ -117,6 +118,9 @@ func (h *MCPHandler) toolCommitMemoryV2(ctx context.Context, workspaceID string, if !ok { return "", fmt.Errorf("workspace %s cannot write to namespace %s", workspaceID, ns) } + if _, err := h.memv2.plugin.UpsertNamespace(ctx, ns, contract.NamespaceUpsert{Kind: kindFromNamespace(ns)}); err != nil { + return "", fmt.Errorf("plugin upsert namespace: %w", err) + } // SAFE-T1201: scrub credential-shaped strings BEFORE the plugin sees // them. Non-negotiable; see memories.go:180. @@ -170,6 +174,19 @@ func (h *MCPHandler) toolCommitMemoryV2(ctx context.Context, workspaceID string, return string(out), nil } +func kindFromNamespace(ns string) contract.NamespaceKind { + switch { + case strings.HasPrefix(ns, "workspace:"): + return contract.NamespaceKindWorkspace + case strings.HasPrefix(ns, "team:"): + return contract.NamespaceKindTeam + case strings.HasPrefix(ns, "org:"): + return contract.NamespaceKindOrg + default: + return contract.NamespaceKindCustom + } +} + // ───────────────────────────────────────────────────────────────────────────── // search_memory // ───────────────────────────────────────────────────────────────────────────── diff --git a/workspace-server/internal/handlers/mcp_tools_memory_v2_test.go b/workspace-server/internal/handlers/mcp_tools_memory_v2_test.go index 93ad91f3c..ff7b34df2 100644 --- a/workspace-server/internal/handlers/mcp_tools_memory_v2_test.go +++ b/workspace-server/internal/handlers/mcp_tools_memory_v2_test.go @@ -20,11 +20,18 @@ import ( // --- stubs --- type stubMemoryPlugin struct { + upsertFn func(ctx context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) commitFn func(ctx context.Context, ns string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) searchFn func(ctx context.Context, body contract.SearchRequest) (*contract.SearchResponse, error) forgetFn func(ctx context.Context, id string, body contract.ForgetRequest) error } +func (s *stubMemoryPlugin) UpsertNamespace(ctx context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) { + if s.upsertFn != nil { + return s.upsertFn(ctx, name, body) + } + return &contract.Namespace{Name: name, Kind: body.Kind}, nil +} func (s *stubMemoryPlugin) CommitMemory(ctx context.Context, ns string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) { if s.commitFn != nil { return s.commitFn(ctx, ns, body) @@ -159,7 +166,15 @@ func TestMemoryV2Available(t *testing.T) { func TestCommitMemoryV2_HappyPathDefaultNamespace(t *testing.T) { db, _, _ := sqlmock.New() defer db.Close() + gotUpsertNS := "" h := newV2Handler(t, db, &stubMemoryPlugin{ + upsertFn: func(_ context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) { + gotUpsertNS = name + if body.Kind != contract.NamespaceKindWorkspace { + t.Errorf("upsert kind = %q, want workspace", body.Kind) + } + return &contract.Namespace{Name: name, Kind: body.Kind}, nil + }, commitFn: func(_ context.Context, ns string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) { if ns != "workspace:root-1" { t.Errorf("ns = %q, want default workspace:root-1", ns) @@ -180,6 +195,9 @@ func TestCommitMemoryV2_HappyPathDefaultNamespace(t *testing.T) { if !strings.Contains(got, `"id":"mem-1"`) { t.Errorf("got = %s", got) } + if gotUpsertNS != "workspace:root-1" { + t.Errorf("upsert namespace = %q, want workspace:root-1", gotUpsertNS) + } } func TestCommitMemoryV2_NamespaceParamUsed(t *testing.T) { diff --git a/workspace-server/internal/handlers/memories_v2_test.go b/workspace-server/internal/handlers/memories_v2_test.go index 14ba1323f..b429eb750 100644 --- a/workspace-server/internal/handlers/memories_v2_test.go +++ b/workspace-server/internal/handlers/memories_v2_test.go @@ -45,6 +45,9 @@ type fakePlugin struct { forgetReq contract.ForgetRequest } +func (f *fakePlugin) UpsertNamespace(ctx context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) { + return &contract.Namespace{Name: name, Kind: body.Kind}, nil +} func (f *fakePlugin) CommitMemory(ctx context.Context, ns string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) { return nil, errors.New("not implemented in fake") } @@ -511,11 +514,11 @@ func TestMemoriesV2_Forget_MissingMemoryID_400(t *testing.T) { // DisplayName over UUID-prefix fallback (issue #2988). func TestNamespaceLabelWithName_PrefersDisplayNameWhenSet(t *testing.T) { cases := []struct { - name string - raw string - kind contract.NamespaceKind - display string - want string + name string + raw string + kind contract.NamespaceKind + display string + want string }{ {"workspace with name", "workspace:abc-1234", contract.NamespaceKindWorkspace, "mac laptop", "Workspace (mac laptop)"}, {"team with name", "team:abc-1234", contract.NamespaceKindTeam, "Engineering", "Team (Engineering)"}, @@ -625,12 +628,12 @@ func TestParseLimit(t *testing.T) { }{ {"", memoriesV2DefaultLimit}, {"10", 10}, - {"0", memoriesV2DefaultLimit}, // ≤0 → default, not error - {"-5", memoriesV2DefaultLimit}, // negative → default - {"abc", memoriesV2DefaultLimit}, // non-numeric → default - {"99999", memoriesV2MaxLimit}, // over cap → clamped - {"100", memoriesV2MaxLimit}, // exactly cap → kept - {"99", 99}, // just under cap → kept + {"0", memoriesV2DefaultLimit}, // ≤0 → default, not error + {"-5", memoriesV2DefaultLimit}, // negative → default + {"abc", memoriesV2DefaultLimit}, // non-numeric → default + {"99999", memoriesV2MaxLimit}, // over cap → clamped + {"100", memoriesV2MaxLimit}, // exactly cap → kept + {"99", 99}, // just under cap → kept } for _, tc := range cases { t.Run("raw="+tc.raw, func(t *testing.T) { @@ -741,11 +744,11 @@ func TestWithMemoryV2_FluentReturnsReceiver(t *testing.T) { func TestShortID(t *testing.T) { cases := map[string]string{ - "": "", - "short": "short", - "exactly8": "exactly8", - "longer-than-eight": "longer-t", - "abc-1234-5678-90ab": "abc-1234", + "": "", + "short": "short", + "exactly8": "exactly8", + "longer-than-eight": "longer-t", + "abc-1234-5678-90ab": "abc-1234", } for in, want := range cases { if got := shortID(in); got != want {