diff --git a/src/Memorizer.IntegrationTests/WorkspaceSlugLookupTests.cs b/src/Memorizer.IntegrationTests/WorkspaceSlugLookupTests.cs
new file mode 100644
index 0000000..caeee0d
--- /dev/null
+++ b/src/Memorizer.IntegrationTests/WorkspaceSlugLookupTests.cs
@@ -0,0 +1,163 @@
+using Memorizer.Extensions;
+using Memorizer.IntegrationTests.Logging;
+using Memorizer.Services;
+using Memorizer.Settings;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using PostgMem.Tools;
+using Xunit.Abstractions;
+
+namespace Memorizer.IntegrationTests;
+
+///
+/// End-to-end tests for resolving workspaces by slug through the real
+/// MCP tool against a live PostgreSQL database.
+/// Exercises the tool logic, slug normalization, and the underlying SQL.
+///
+[Collection(nameof(IntegrationTestCollection))]
+public class WorkspaceSlugLookupTests : IDisposable
+{
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly IServiceProvider _services;
+
+ public WorkspaceSlugLookupTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ _services = CreateServices();
+ }
+
+ public void Dispose() => (_services as IDisposable)?.Dispose();
+
+ private IServiceProvider CreateServices()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSingleton(new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString,
+ ["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl,
+ ["Embeddings:Model"] = "all-minilm",
+ ["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString()
+ })
+ .Build());
+
+ services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new Uri(_fixture.OllamaApiUrl);
+ client.Timeout = TimeSpan.FromMinutes(1);
+ });
+
+ services.AddSingleton(new EmbeddingSettings
+ {
+ ApiUrl = new Uri(_fixture.OllamaApiUrl),
+ Model = "all-minilm",
+ Timeout = TimeSpan.FromMinutes(1)
+ });
+
+ services.AddMemorizer();
+ services.AddLogging(builder => builder.AddXUnit(_output));
+
+ return services.BuildServiceProvider();
+ }
+
+ private WorkspaceTools CreateTools()
+ {
+ var storage = _services.GetRequiredService();
+ var urlService = _services.GetRequiredService();
+ return new WorkspaceTools(storage, NullLogger.Instance, urlService);
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_ResolvesWorkspaceEndToEnd()
+ {
+ var storage = _services.GetRequiredService();
+ var tools = CreateTools();
+
+ var created = await storage.CreateWorkspaceAsync("Engineering", "Root workspace", cancellationToken: default);
+ _output.WriteLine($"Created workspace {created.Id.Value} with slug '{created.Slug}'");
+
+ try
+ {
+ var result = await tools.GetWorkspace(slug: created.Slug);
+
+ Assert.Contains("Workspace: Engineering", result);
+ Assert.Contains($"Slug: {created.Slug}", result);
+ Assert.Contains(created.Id.Value.ToString(), result);
+ }
+ finally
+ {
+ await storage.DeleteWorkspaceAsync(created.Id, default);
+ }
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_IsCaseInsensitive()
+ {
+ var storage = _services.GetRequiredService();
+ var tools = CreateTools();
+
+ var created = await storage.CreateWorkspaceAsync("Platform Team", cancellationToken: default);
+ _output.WriteLine($"Created workspace with slug '{created.Slug}'");
+
+ try
+ {
+ // Caller passes an upper-cased slug; tool normalizes to the stored lowercase form.
+ var result = await tools.GetWorkspace(slug: created.Slug.ToUpperInvariant());
+
+ Assert.Contains("Workspace: Platform Team", result);
+ Assert.Contains($"Slug: {created.Slug}", result);
+ }
+ finally
+ {
+ await storage.DeleteWorkspaceAsync(created.Id, default);
+ }
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_ScopesToParent()
+ {
+ var storage = _services.GetRequiredService();
+ var tools = CreateTools();
+
+ // Same slug at root and nested under a parent — parentWorkspaceId disambiguates.
+ var parent = await storage.CreateWorkspaceAsync("Platform", cancellationToken: default);
+ var rootDupe = await storage.CreateWorkspaceAsync("Backend", "root scope", cancellationToken: default);
+ var childDupe = await storage.CreateWorkspaceAsync("Backend", "child scope", parentId: parent.Id, cancellationToken: default);
+
+ Assert.Equal(rootDupe.Slug, childDupe.Slug);
+ _output.WriteLine($"Root '{rootDupe.Id.Value}' and child '{childDupe.Id.Value}' share slug '{rootDupe.Slug}'");
+
+ try
+ {
+ var rootResult = await tools.GetWorkspace(slug: rootDupe.Slug);
+ var childResult = await tools.GetWorkspace(slug: childDupe.Slug, parentWorkspaceId: parent.Id.Value.ToString());
+
+ Assert.Contains(rootDupe.Id.Value.ToString(), rootResult);
+ Assert.DoesNotContain(childDupe.Id.Value.ToString(), rootResult);
+
+ Assert.Contains(childDupe.Id.Value.ToString(), childResult);
+ Assert.DoesNotContain(rootDupe.Id.Value.ToString(), childResult);
+ }
+ finally
+ {
+ await storage.DeleteWorkspaceAsync(childDupe.Id, default);
+ await storage.DeleteWorkspaceAsync(rootDupe.Id, default);
+ await storage.DeleteWorkspaceAsync(parent.Id, default);
+ }
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_WhenMissing_ReturnsNotFound()
+ {
+ var tools = CreateTools();
+
+ var result = await tools.GetWorkspace(slug: "definitely-does-not-exist");
+
+ Assert.Contains("Workspace with slug 'definitely-does-not-exist' not found among root workspaces.", result);
+ }
+}
diff --git a/src/Memorizer.UnitTests/Tools/FakeCanonicalUrlService.cs b/src/Memorizer.UnitTests/Tools/FakeCanonicalUrlService.cs
new file mode 100644
index 0000000..23ea0fa
--- /dev/null
+++ b/src/Memorizer.UnitTests/Tools/FakeCanonicalUrlService.cs
@@ -0,0 +1,35 @@
+using Memorizer.Models;
+using Memorizer.Models.ValueTypes;
+using Memorizer.Services;
+
+namespace Memorizer.UnitTests.Tools;
+
+///
+/// Fake canonical URL service for workspace/project tool tests.
+///
+internal class FakeCanonicalUrlService : ICanonicalUrlService
+{
+ public bool IsConfigured { get; set; }
+ public string BaseUrl { get; set; } = "";
+ public bool GetMemoryUrlCalled { get; private set; }
+ public bool GetWorkspaceUrlCalled { get; private set; }
+ public bool GetProjectUrlCalled { get; private set; }
+
+ public string? GetMemoryUrl(MemoryId memoryId)
+ {
+ GetMemoryUrlCalled = true;
+ return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/view/{memoryId.Value}" : null;
+ }
+
+ public string? GetWorkspaceUrl(WorkspaceId workspaceId)
+ {
+ GetWorkspaceUrlCalled = true;
+ return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/workspace/{workspaceId.Value}" : null;
+ }
+
+ public string? GetProjectUrl(ProjectId projectId)
+ {
+ GetProjectUrlCalled = true;
+ return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/project/{projectId.Value}" : null;
+ }
+}
diff --git a/src/Memorizer.UnitTests/Tools/FakeWorkspaceStorage.cs b/src/Memorizer.UnitTests/Tools/FakeWorkspaceStorage.cs
new file mode 100644
index 0000000..1010931
--- /dev/null
+++ b/src/Memorizer.UnitTests/Tools/FakeWorkspaceStorage.cs
@@ -0,0 +1,185 @@
+using Memorizer.Models;
+using Memorizer.Models.Enums;
+using Memorizer.Models.ValueTypes;
+using Memorizer.Services;
+using Memorizer.Settings;
+using Pgvector;
+
+namespace Memorizer.UnitTests.Tools;
+
+///
+/// Fake storage that implements just enough of IStorage for workspace/project tool tests.
+/// Uses manual fakes instead of a mocking framework.
+///
+internal class FakeWorkspaceStorage : IStorage
+{
+ public Workspace? Workspace { get; set; }
+ public Workspace? CreatedWorkspace { get; set; }
+ public Workspace? WorkspaceBySlug { get; set; }
+ public Project? Project { get; set; }
+ public Project? CreatedProject { get; set; }
+
+ // Captured arguments for slug lookups
+ public string? LastSlugQueried { get; private set; }
+ public WorkspaceId? LastParentIdQueried { get; private set; }
+
+ // Workspace operations used by tests
+ public Task GetWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default)
+ => Task.FromResult(Workspace);
+
+ public Task CreateWorkspaceAsync(string name, string? description = null, WorkspaceId? parentId = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(CreatedWorkspace ?? throw new InvalidOperationException("CreatedWorkspace not set"));
+
+ public Task> GetWorkspacesAsync(WorkspaceId? parentId = null, bool includeSystem = false, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+
+ public Task> GetWorkspacePathAsync(WorkspaceId id, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+
+ public Task UpdateWorkspaceAsync(WorkspaceId id, string? name = null, string? description = null, WorkspaceId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+
+ public Task MoveProjectToWorkspaceAsync(ProjectId id, WorkspaceId newWorkspaceId, ProjectId? newParentId = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+
+ public Task DeleteWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+
+ public Task> SearchWorkspacesAsync(string query, bool includeSystem = false, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+
+ public Task GetWorkspaceBySlugAsync(string slug, WorkspaceId? parentId = null, CancellationToken cancellationToken = default)
+ {
+ LastSlugQueried = slug;
+ LastParentIdQueried = parentId;
+ return Task.FromResult(WorkspaceBySlug);
+ }
+
+ // Project operations used by tests
+ public Task GetProjectAsync(ProjectId id, CancellationToken cancellationToken = default)
+ => Task.FromResult(Project);
+
+ public Task CreateProjectAsync(WorkspaceId workspaceId, string name, string? description = null, ProjectId? parentId = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set"));
+
+ public Task> GetProjectsAsync(WorkspaceId workspaceId, ProjectId? parentId = null, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+
+ public Task GetProjectPathAsync(ProjectId id, CancellationToken cancellationToken = default)
+ => Task.FromResult(new ProjectPath { WorkspacePath = Array.Empty(), ProjectAncestors = Array.Empty() });
+
+ public Task UpdateProjectAsync(ProjectId id, string? name = null, string? description = null, ProjectStatusEnum? status = null, string? victoryConditions = null, ProjectId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default)
+ => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set"));
+
+ public Task DeleteProjectAsync(ProjectId id, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+
+ public Task> SearchProjectsAsync(string query, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+
+ // Memory operations (stubs)
+ public Task StoreMemory(string type, string content, string source, string[]? tags, Confidence confidence, string title, MemoryId? relatedTo = null, string? relationshipType = null, MemoryOwner? owner = null, ArchetypeEnum archetype = ArchetypeEnum.Document, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task Get(MemoryId id, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetMany(IEnumerable ids, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task Delete(MemoryId id, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task UpdateMemory(MemoryId id, string type, string content, string source, string[]? tags, Confidence confidence, string? title, CancellationToken cancellationToken)
+ => throw new NotImplementedException();
+ public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, SimilarityScore? score, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetRelationships(MemoryId memoryId, string? type = null, bool includeArchivedTargets = false, CancellationToken cancellationToken = default)
+ => Task.FromResult(new List());
+ public Task> GetSimilarMemories(MemoryId memoryId, SimilarityScore? minSimilarity = null, int limit = 10, CancellationToken cancellationToken = default)
+ => Task.FromResult(new List());
+ public Task> GetEvents(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetVersionHistory(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task GetVersion(MemoryId memoryId, VersionNumber versionNumber, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task RevertToVersion(MemoryId memoryId, VersionNumber versionNumber, string? changedBy = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task PurgeVersionsKeepingLatest(MemoryId memoryId, int versionsToKeep, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task PurgeVersionsOlderThan(DateTime cutoffDate, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task GetVersionStats(CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> Search(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task<(List Memories, int TotalCount)> GetMemoriesPaginated(int page = 1, int pageSize = 20, string? memoryType = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetDistinctMemoryTypes(CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetDistinctTagsAsync(MemoryOwner? owner = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetDistinctOwnersAsync(string[]? tags = null, string? memoryType = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new List());
+ public Task> GetMemoriesWithoutTitles(int limit = 50, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task UpdateMemoryTitle(MemoryId id, string title, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task UpdateMemoryOwner(MemoryId id, MemoryOwner owner, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task SetMemoryOwnerAsync(MemoryId memoryId, MemoryOwner owner, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+ public Task MoveMemoryToUnfiledAsync(MemoryId memoryId, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+ public Task CountMemoriesWithoutMetadataEmbeddings(CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetMemoriesWithoutMetadataEmbeddings(int limit, bool includeExisting = false, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task UpdateMemoryMetadataEmbedding(MemoryId memoryId, Vector embedding, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task UpdateMemoryEmbeddings(MemoryId memoryId, Vector contentEmbedding, Vector metadataEmbedding, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task UpdateMemoryArchetypeAsync(MemoryId memoryId, ArchetypeEnum newArchetype, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task<(IReadOnlyList Memories, int TotalCount)> GetArchivedMemoriesAsync(int page = 1, int pageSize = 50, ProjectId? projectId = null, CancellationToken cancellationToken = default)
+ => Task.FromResult<(IReadOnlyList Memories, int TotalCount)>((new List(), 0));
+ public Task> GetMemoriesByOwnerAsync(MemoryOwner owner, int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+ public Task GetMemoryCountByOwnerAsync(MemoryOwner owner, string? memoryType = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(0);
+ public Task> GetUnfiledMemoriesAsync(int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default)
+ => Task.FromResult>(new List());
+ public Task GetUnfiledMemoryCountAsync(string? memoryType = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(0);
+ public Task<(IReadOnlyList Memories, int TotalCount)> GetMemoriesByTagAsync(string[] tags, int page = 1, int pageSize = 20, MemoryOwner? owner = null, string? memoryType = null, CancellationToken cancellationToken = default)
+ => Task.FromResult<(IReadOnlyList, int)>((new List(), 0));
+
+ // Provider settings
+ public Task GetActiveProviderAsync(string providerType, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task> GetAllProvidersAsync(string providerType, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task SaveProviderSettingsAsync(ProviderSettings settings, CancellationToken cancellationToken = default)
+ => throw new NotImplementedException();
+ public Task SetActiveProviderAsync(string providerType, string providerName, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+
+ // System memory seeding
+ public Task<(int ProjectsSeeded, int WorkspacesSeeded)> SeedProjectAndWorkspaceSystemMemoriesAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult((0, 0));
+
+ // Data migration tracking
+ public Task HasDataMigrationRunAsync(string migrationName, CancellationToken cancellationToken = default)
+ => Task.FromResult(false);
+ public Task RecordDataMigrationAsync(string migrationName, string? description = null, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+ public Task ExecuteDataMigrationIfNeededAsync(string migrationName, string description, Func migrationAction, CancellationToken cancellationToken = default)
+ => Task.FromResult(false);
+}
diff --git a/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs b/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs
index 63c4c58..7a171b4 100644
--- a/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs
+++ b/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs
@@ -1,12 +1,8 @@
using Memorizer.Models;
using Memorizer.Models.Enums;
using Memorizer.Models.ValueTypes;
-using Memorizer.Services;
-using Memorizer.Settings;
using Microsoft.Extensions.Logging.Abstractions;
using PostgMem.Tools;
-using Pgvector;
-using System.Text.Json;
namespace Memorizer.UnitTests.Tools;
@@ -204,198 +200,4 @@ private static Project CreateTestProject(ProjectId id, WorkspaceId workspaceId,
VictoryConditions = null
};
}
-
- ///
- /// Fake storage that implements just enough of IStorage for workspace/project tests
- ///
- private class FakeWorkspaceStorage : IStorage
- {
- public Workspace? Workspace { get; set; }
- public Workspace? CreatedWorkspace { get; set; }
- public Project? Project { get; set; }
- public Project? CreatedProject { get; set; }
-
- // Workspace operations used by tests
- public Task GetWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default)
- => Task.FromResult(Workspace);
-
- public Task CreateWorkspaceAsync(string name, string? description = null, WorkspaceId? parentId = null, CancellationToken cancellationToken = default)
- => Task.FromResult(CreatedWorkspace ?? throw new InvalidOperationException("CreatedWorkspace not set"));
-
- public Task> GetWorkspacesAsync(WorkspaceId? parentId = null, bool includeSystem = false, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
-
- public Task> GetWorkspacePathAsync(WorkspaceId id, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
-
- public Task UpdateWorkspaceAsync(WorkspaceId id, string? name = null, string? description = null, WorkspaceId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
-
- public Task MoveProjectToWorkspaceAsync(ProjectId id, WorkspaceId newWorkspaceId, ProjectId? newParentId = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
-
- public Task DeleteWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
-
- public Task> SearchWorkspacesAsync(string query, bool includeSystem = false, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
-
- public Task GetWorkspaceBySlugAsync(string slug, WorkspaceId? parentId = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
-
- // Project operations used by tests
- public Task GetProjectAsync(ProjectId id, CancellationToken cancellationToken = default)
- => Task.FromResult(Project);
-
- public Task CreateProjectAsync(WorkspaceId workspaceId, string name, string? description = null, ProjectId? parentId = null, CancellationToken cancellationToken = default)
- => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set"));
-
- public Task> GetProjectsAsync(WorkspaceId workspaceId, ProjectId? parentId = null, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
-
- public Task GetProjectPathAsync(ProjectId id, CancellationToken cancellationToken = default)
- => Task.FromResult(new ProjectPath { WorkspacePath = Array.Empty(), ProjectAncestors = Array.Empty() });
-
- public Task UpdateProjectAsync(ProjectId id, string? name = null, string? description = null, ProjectStatusEnum? status = null, string? victoryConditions = null, ProjectId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default)
- => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set"));
-
- public Task DeleteProjectAsync(ProjectId id, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
-
- public Task> SearchProjectsAsync(string query, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
-
- // Memory operations (stubs)
- public Task StoreMemory(string type, string content, string source, string[]? tags, Confidence confidence, string title, MemoryId? relatedTo = null, string? relationshipType = null, MemoryOwner? owner = null, ArchetypeEnum archetype = ArchetypeEnum.Document, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task Get(MemoryId id, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetMany(IEnumerable ids, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task Delete(MemoryId id, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task UpdateMemory(MemoryId id, string type, string content, string source, string[]? tags, Confidence confidence, string? title, CancellationToken cancellationToken)
- => throw new NotImplementedException();
- public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, SimilarityScore? score, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetRelationships(MemoryId memoryId, string? type = null, bool includeArchivedTargets = false, CancellationToken cancellationToken = default)
- => Task.FromResult(new List());
- public Task> GetSimilarMemories(MemoryId memoryId, SimilarityScore? minSimilarity = null, int limit = 10, CancellationToken cancellationToken = default)
- => Task.FromResult(new List());
- public Task> GetEvents(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetVersionHistory(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task GetVersion(MemoryId memoryId, VersionNumber versionNumber, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task RevertToVersion(MemoryId memoryId, VersionNumber versionNumber, string? changedBy = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task PurgeVersionsKeepingLatest(MemoryId memoryId, int versionsToKeep, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task PurgeVersionsOlderThan(DateTime cutoffDate, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task GetVersionStats(CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> Search(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task<(List Memories, int TotalCount)> GetMemoriesPaginated(int page = 1, int pageSize = 20, string? memoryType = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetDistinctMemoryTypes(CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetDistinctTagsAsync(MemoryOwner? owner = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetDistinctOwnersAsync(string[]? tags = null, string? memoryType = null, CancellationToken cancellationToken = default)
- => Task.FromResult(new List());
- public Task> GetMemoriesWithoutTitles(int limit = 50, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task UpdateMemoryTitle(MemoryId id, string title, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task UpdateMemoryOwner(MemoryId id, MemoryOwner owner, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task SetMemoryOwnerAsync(MemoryId memoryId, MemoryOwner owner, CancellationToken cancellationToken = default)
- => Task.CompletedTask;
- public Task MoveMemoryToUnfiledAsync(MemoryId memoryId, CancellationToken cancellationToken = default)
- => Task.CompletedTask;
- public Task CountMemoriesWithoutMetadataEmbeddings(CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetMemoriesWithoutMetadataEmbeddings(int limit, bool includeExisting = false, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task UpdateMemoryMetadataEmbedding(MemoryId memoryId, Vector embedding, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task UpdateMemoryEmbeddings(MemoryId memoryId, Vector contentEmbedding, Vector metadataEmbedding, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task UpdateMemoryArchetypeAsync(MemoryId memoryId, ArchetypeEnum newArchetype, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task<(IReadOnlyList Memories, int TotalCount)> GetArchivedMemoriesAsync(int page = 1, int pageSize = 50, ProjectId? projectId = null, CancellationToken cancellationToken = default)
- => Task.FromResult<(IReadOnlyList Memories, int TotalCount)>((new List(), 0));
- public Task> GetMemoriesByOwnerAsync(MemoryOwner owner, int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
- public Task GetMemoryCountByOwnerAsync(MemoryOwner owner, string? memoryType = null, CancellationToken cancellationToken = default)
- => Task.FromResult(0);
- public Task> GetUnfiledMemoriesAsync(int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default)
- => Task.FromResult>(new List());
- public Task GetUnfiledMemoryCountAsync(string? memoryType = null, CancellationToken cancellationToken = default)
- => Task.FromResult(0);
- public Task<(IReadOnlyList Memories, int TotalCount)> GetMemoriesByTagAsync(string[] tags, int page = 1, int pageSize = 20, MemoryOwner? owner = null, string? memoryType = null, CancellationToken cancellationToken = default)
- => Task.FromResult<(IReadOnlyList, int)>((new List(), 0));
-
- // Provider settings
- public Task GetActiveProviderAsync(string providerType, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task> GetAllProvidersAsync(string providerType, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task SaveProviderSettingsAsync(ProviderSettings settings, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
- public Task SetActiveProviderAsync(string providerType, string providerName, CancellationToken cancellationToken = default)
- => Task.CompletedTask;
-
- // System memory seeding
- public Task<(int ProjectsSeeded, int WorkspacesSeeded)> SeedProjectAndWorkspaceSystemMemoriesAsync(CancellationToken cancellationToken = default)
- => Task.FromResult((0, 0));
-
- // Data migration tracking
- public Task HasDataMigrationRunAsync(string migrationName, CancellationToken cancellationToken = default)
- => Task.FromResult(false);
- public Task RecordDataMigrationAsync(string migrationName, string? description = null, CancellationToken cancellationToken = default)
- => Task.CompletedTask;
- public Task ExecuteDataMigrationIfNeededAsync(string migrationName, string description, Func migrationAction, CancellationToken cancellationToken = default)
- => Task.FromResult(false);
- }
-
- private class FakeCanonicalUrlService : ICanonicalUrlService
- {
- public bool IsConfigured { get; set; }
- public string BaseUrl { get; set; } = "";
- public bool GetMemoryUrlCalled { get; private set; }
- public bool GetWorkspaceUrlCalled { get; private set; }
- public bool GetProjectUrlCalled { get; private set; }
-
- public string? GetMemoryUrl(MemoryId memoryId)
- {
- GetMemoryUrlCalled = true;
- return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/view/{memoryId.Value}" : null;
- }
-
- public string? GetWorkspaceUrl(WorkspaceId workspaceId)
- {
- GetWorkspaceUrlCalled = true;
- return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/workspace/{workspaceId.Value}" : null;
- }
-
- public string? GetProjectUrl(ProjectId projectId)
- {
- GetProjectUrlCalled = true;
- return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/project/{projectId.Value}" : null;
- }
- }
}
diff --git a/src/Memorizer.UnitTests/Tools/WorkspaceToolsSlugTests.cs b/src/Memorizer.UnitTests/Tools/WorkspaceToolsSlugTests.cs
new file mode 100644
index 0000000..7fe514f
--- /dev/null
+++ b/src/Memorizer.UnitTests/Tools/WorkspaceToolsSlugTests.cs
@@ -0,0 +1,146 @@
+using Memorizer.Models;
+using Memorizer.Models.ValueTypes;
+using Microsoft.Extensions.Logging.Abstractions;
+using PostgMem.Tools;
+
+namespace Memorizer.UnitTests.Tools;
+
+///
+/// Tests that verify the GetWorkspace MCP tool resolves workspaces by slug.
+/// Uses manual fakes instead of a mocking framework.
+///
+public class WorkspaceToolsSlugTests
+{
+ [Fact]
+ public async Task GetWorkspace_BySlug_ShouldReturnWorkspaceDetails()
+ {
+ // Arrange
+ var workspaceId = new WorkspaceId(Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"));
+ var fakeStorage = new FakeWorkspaceStorage
+ {
+ WorkspaceBySlug = CreateTestWorkspace(workspaceId, "Engineering")
+ };
+ var tools = CreateTools(fakeStorage);
+
+ // Act
+ var result = await tools.GetWorkspace(slug: "engineering");
+
+ // Assert
+ Assert.Contains("Workspace: Engineering", result);
+ Assert.Contains("Slug: engineering", result);
+ Assert.Equal("engineering", fakeStorage.LastSlugQueried);
+ Assert.Null(fakeStorage.LastParentIdQueried);
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_WithParent_ShouldScopeLookupToParent()
+ {
+ // Arrange
+ var workspaceId = new WorkspaceId(Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"));
+ var parentId = new WorkspaceId(Guid.Parse("a1874a6b-8a15-4da6-a413-99bf3249d1e4"));
+ var fakeStorage = new FakeWorkspaceStorage
+ {
+ WorkspaceBySlug = CreateTestWorkspace(workspaceId, "Backend")
+ };
+ var tools = CreateTools(fakeStorage);
+
+ // Act
+ var result = await tools.GetWorkspace(slug: "backend", parentWorkspaceId: parentId.Value.ToString());
+
+ // Assert
+ Assert.Contains("Workspace: Backend", result);
+ Assert.Equal("backend", fakeStorage.LastSlugQueried);
+ Assert.Equal(parentId, fakeStorage.LastParentIdQueried);
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_ShouldNormalizeToLowercase()
+ {
+ // Arrange
+ var workspaceId = new WorkspaceId(Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"));
+ var fakeStorage = new FakeWorkspaceStorage
+ {
+ WorkspaceBySlug = CreateTestWorkspace(workspaceId, "Engineering")
+ };
+ var tools = CreateTools(fakeStorage);
+
+ // Act: caller passes a mixed-case, padded slug
+ var result = await tools.GetWorkspace(slug: " Engineering ");
+
+ // Assert: storage is queried with the canonical lowercase, trimmed slug
+ Assert.Equal("engineering", fakeStorage.LastSlugQueried);
+ Assert.Contains("Workspace: Engineering", result);
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_WhenNotFound_ShouldReturnNotFoundMessage()
+ {
+ // Arrange
+ var fakeStorage = new FakeWorkspaceStorage
+ {
+ WorkspaceBySlug = null
+ };
+ var tools = CreateTools(fakeStorage);
+
+ // Act
+ var result = await tools.GetWorkspace(slug: "missing");
+
+ // Assert
+ Assert.Contains("Workspace with slug 'missing' not found among root workspaces.", result);
+ }
+
+ [Fact]
+ public async Task GetWorkspace_BySlug_WhenNotFoundUnderParent_ShouldMentionParentScope()
+ {
+ // Arrange
+ var parentId = new WorkspaceId(Guid.Parse("a1874a6b-8a15-4da6-a413-99bf3249d1e4"));
+ var fakeStorage = new FakeWorkspaceStorage
+ {
+ WorkspaceBySlug = null
+ };
+ var tools = CreateTools(fakeStorage);
+
+ // Act
+ var result = await tools.GetWorkspace(slug: "missing", parentWorkspaceId: parentId.Value.ToString());
+
+ // Assert
+ Assert.Contains($"Workspace with slug 'missing' not found under parent {parentId.Value}", result);
+ }
+
+ [Fact]
+ public async Task GetWorkspace_QueryTakesPrecedenceOverSlug()
+ {
+ // Arrange
+ var fakeStorage = new FakeWorkspaceStorage();
+ var tools = CreateTools(fakeStorage);
+
+ // Act
+ var result = await tools.GetWorkspace(slug: "engineering", query: "eng");
+
+ // Assert: query path was taken, so slug lookup was never invoked
+ Assert.Null(fakeStorage.LastSlugQueried);
+ Assert.Contains("No workspaces found matching 'eng'", result);
+ }
+
+ private static WorkspaceTools CreateTools(FakeWorkspaceStorage storage)
+ {
+ var logger = new NullLogger();
+ var urlService = new FakeCanonicalUrlService { IsConfigured = false };
+ return new WorkspaceTools(storage, logger, urlService);
+ }
+
+ private static Workspace CreateTestWorkspace(WorkspaceId id, string name)
+ {
+ return new Workspace
+ {
+ Id = id,
+ Name = name,
+ Slug = name.ToLower().Replace(" ", "-"),
+ Description = null,
+ IsSystem = false,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ ParentId = null
+ };
+ }
+}
diff --git a/src/Memorizer/Tools/WorkspaceTools.cs b/src/Memorizer/Tools/WorkspaceTools.cs
index 8caa8f4..36bd2f3 100644
--- a/src/Memorizer/Tools/WorkspaceTools.cs
+++ b/src/Memorizer/Tools/WorkspaceTools.cs
@@ -29,10 +29,12 @@ public WorkspaceTools(IStorage storage, ILogger logger, ICanonic
// ===== Workspace Tools =====
- [McpServerTool, Description("Get workspace information. Without an ID, lists root workspaces with hints about nested content. With an ID, shows detailed workspace info. With a query, searches all workspaces by name. Workspaces are organizational containers (e.g., 'Engineering', 'Sales') that persist indefinitely and can be nested.")]
+ [McpServerTool, Description("Get workspace information. Without an ID, lists root workspaces with hints about nested content. With an ID, shows detailed workspace info. With a slug, looks up a workspace by its URL-safe identifier (optionally scoped to a parent). With a query, searches all workspaces by name. Workspaces are organizational containers (e.g., 'Engineering', 'Sales') that persist indefinitely and can be nested.")]
public async Task GetWorkspace(
[Description("Optional workspace ID. If omitted, lists root workspaces.")] string? workspaceId = null,
+ [Description("Optional workspace slug (URL-safe identifier). Unique within its parent scope; use parentWorkspaceId to disambiguate nested workspaces.")] string? slug = null,
[Description("Optional search query to find workspaces by name (searches all levels). Case-insensitive partial match.")] string? query = null,
+ [Description("Optional parent workspace ID to scope a slug lookup. Omit to look up a slug among root workspaces.")] string? parentWorkspaceId = null,
[Description("Include system workspaces (like 'Unfiled'). Only applies when listing workspaces.")] bool includeSystem = false,
CancellationToken cancellationToken = default
)
@@ -43,6 +45,22 @@ public async Task GetWorkspace(
return await SearchWorkspacesAsync(query, includeSystem, cancellationToken);
}
+ // If slug is provided, look it up (optionally scoped to a parent workspace)
+ if (!string.IsNullOrWhiteSpace(slug))
+ {
+ // Slugs are stored lowercase (see GenerateSlug); normalize so lookups are case-insensitive.
+ var normalizedSlug = slug.Trim().ToLowerInvariant();
+ var parsedParentId = ParseOptionalGuid(parentWorkspaceId);
+ var parentId = parsedParentId.HasValue ? new WorkspaceId(parsedParentId.Value) : (WorkspaceId?)null;
+ var bySlug = await _storage.GetWorkspaceBySlugAsync(normalizedSlug, parentId, cancellationToken);
+ if (bySlug == null)
+ {
+ var scope = parentId.HasValue ? $" under parent {parentId.Value.Value}" : " among root workspaces";
+ return $"Workspace with slug '{normalizedSlug}' not found{scope}.";
+ }
+ return await GetWorkspaceDetailsAsync(bySlug, cancellationToken);
+ }
+
// Parse optional Guid defensively — MCP clients may send empty strings or "null"
var parsedWorkspaceId = ParseOptionalGuid(workspaceId);
@@ -53,7 +71,12 @@ public async Task GetWorkspace(
}
// Get specific workspace details
- return await GetWorkspaceDetailsAsync(new WorkspaceId(parsedWorkspaceId.Value), cancellationToken);
+ var workspace = await _storage.GetWorkspaceAsync(new WorkspaceId(parsedWorkspaceId.Value), cancellationToken);
+ if (workspace == null)
+ {
+ return $"Workspace with ID {parsedWorkspaceId.Value} not found.";
+ }
+ return await GetWorkspaceDetailsAsync(workspace, cancellationToken);
}
private async Task SearchWorkspacesAsync(string query, bool includeSystem, CancellationToken cancellationToken)
@@ -131,14 +154,9 @@ private async Task ListRootWorkspacesAsync(bool includeSystem, Cancellat
return result.ToString();
}
- private async Task GetWorkspaceDetailsAsync(WorkspaceId workspaceId, CancellationToken cancellationToken)
+ private async Task GetWorkspaceDetailsAsync(Workspace workspace, CancellationToken cancellationToken)
{
- var workspace = await _storage.GetWorkspaceAsync(workspaceId, cancellationToken);
-
- if (workspace == null)
- {
- return $"Workspace with ID {workspaceId.Value} not found.";
- }
+ var workspaceId = workspace.Id;
// Get path for breadcrumb
var path = await _storage.GetWorkspacePathAsync(workspaceId, cancellationToken);