From 0aea321a4e924a2ee97ef5294fc4ed06ad029bbd Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:18:23 -0500 Subject: [PATCH 1/5] Change package-ecosystem from npm to nuget (#37) --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e8faf3f..907da0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values + - package-ecosystem: "nuget" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" From b89bd6602c5a509c5fe6a33d6a4492a15b6ab10a Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:25:00 -0500 Subject: [PATCH 2/5] Mcp/Storage fixes (#38) * Remove Azure Table Storage service registration Removed Azure Table Storage registration from services. * Refactor TableStorageService constructor for config Refactored constructor to initialize TableServiceClient based on environment variables for better configuration handling. * Refactor namespace and add Azure.Identity using * Move KnowledgeGraphService to Services namespace * Move namespace for RelationService to Services * Add using directives for Services and Storage * Add Storage namespace to RelationService * Add using directive for Storage namespace * Refactor GraphFunctions to use constructor parameters * Refactor relation ID handling in RelationService * Refactor RelationService and add TableStorageService * Refactor RelationService to implement IRelationService --- .../Functions/GraphFunctions.cs | 25 ++- CentralMemoryMcp.Functions/Program.cs | 11 +- .../Services/KnowledgeGraphService.cs | 3 +- .../Services/RelationService.cs | 142 ++++++++++++++++-- .../Storage/TableStorageService.cs | 18 ++- 5 files changed, 161 insertions(+), 38 deletions(-) diff --git a/CentralMemoryMcp.Functions/Functions/GraphFunctions.cs b/CentralMemoryMcp.Functions/Functions/GraphFunctions.cs index c5c5d82..77dded1 100644 --- a/CentralMemoryMcp.Functions/Functions/GraphFunctions.cs +++ b/CentralMemoryMcp.Functions/Functions/GraphFunctions.cs @@ -2,20 +2,13 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Extensions.Mcp; using System.ComponentModel; -using System; // for Guid +using CentralMemoryMcp.Functions.Services; namespace CentralMemoryMcp.Functions.Functions; -public class GraphFunctions +public class GraphFunctions(IKnowledgeGraphService graph, IRelationService relations) { - private readonly IKnowledgeGraphService _graph; - private readonly IRelationService _relations; - - public GraphFunctions(IKnowledgeGraphService graph, IRelationService relations) - { - _graph = graph; - _relations = relations; - } + private readonly IRelationService _relations = relations; [Function(nameof(ReadGraph))] public async Task ReadGraph( @@ -24,7 +17,7 @@ public async Task ReadGraph( [McpToolProperty("workspaceName", "The unique identifier of the workspace.", isRequired: true)] string workspaceName) { - var entities = await _graph.ReadGraphAsync(workspaceName); + var entities = await graph.ReadGraphAsync(workspaceName); var relations = await _relations.GetRelationsForWorkspaceAsync(workspaceName); return new @@ -55,7 +48,7 @@ public async Task UpsertEntity( request.Observations ?? [], request.Metadata); - model = await _graph.UpsertEntityAsync(model); // capture potentially reused Id + model = await graph.UpsertEntityAsync(model); // capture potentially reused Id return new { success = true, id = model.Id, workspace = model.WorkspaceName, name = model.Name }; } @@ -80,12 +73,12 @@ public async Task UpsertRelation( if (fromId == Guid.Empty && !string.IsNullOrWhiteSpace(request.From)) { - var entity = await _graph.GetEntityAsync(request.WorkspaceName, request.From); + var entity = await graph.GetEntityAsync(request.WorkspaceName, request.From); if (entity is not null) fromId = entity.Id; else return new { success = false, message = $"Source entity '{request.From}' not found." }; } if (toId == Guid.Empty && !string.IsNullOrWhiteSpace(request.To)) { - var entity = await _graph.GetEntityAsync(request.WorkspaceName, request.To); + var entity = await graph.GetEntityAsync(request.WorkspaceName, request.To); if (entity is not null) toId = entity.Id; else return new { success = false, message = $"Target entity '{request.To}' not found." }; } @@ -116,14 +109,14 @@ public async Task GetEntityRelations( [McpToolProperty("entityName", "Legacy entity name (used if entityId not provided).", isRequired: false)] string? entityName) { - Guid resolvedId = Guid.Empty; + Guid resolvedId; if (entityId.HasValue && entityId.Value != Guid.Empty) { resolvedId = entityId.Value; } else if (!string.IsNullOrWhiteSpace(entityName)) { - var entity = await _graph.GetEntityAsync(workspaceName, entityName); + var entity = await graph.GetEntityAsync(workspaceName, entityName); if (entity is null) { return new { success = false, message = $"Entity '{entityName}' not found in workspace '{workspaceName}'." }; diff --git a/CentralMemoryMcp.Functions/Program.cs b/CentralMemoryMcp.Functions/Program.cs index 1b64f6e..f39a9c9 100644 --- a/CentralMemoryMcp.Functions/Program.cs +++ b/CentralMemoryMcp.Functions/Program.cs @@ -1,5 +1,6 @@ using Azure.Data.Tables; -using CentralMemoryMcp.Functions; +using CentralMemoryMcp.Functions.Services; +using CentralMemoryMcp.Functions.Storage; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -10,14 +11,6 @@ // Services builder.ConfigureFunctionsWebApplication(); -// Register Azure Table Storage -builder.Services.AddSingleton(sp => -{ - var connectionString = Environment.GetEnvironmentVariable("AzureWebJobsStorage") - ?? throw new InvalidOperationException("AzureWebJobsStorage connection string is not configured."); - return new TableServiceClient(connectionString); -}); - // Register application services builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/CentralMemoryMcp.Functions/Services/KnowledgeGraphService.cs b/CentralMemoryMcp.Functions/Services/KnowledgeGraphService.cs index 4f0d8fe..d399366 100644 --- a/CentralMemoryMcp.Functions/Services/KnowledgeGraphService.cs +++ b/CentralMemoryMcp.Functions/Services/KnowledgeGraphService.cs @@ -1,8 +1,9 @@ using Azure; using Azure.Data.Tables; using CentralMemoryMcp.Functions.Models; +using CentralMemoryMcp.Functions.Storage; -namespace CentralMemoryMcp.Functions; +namespace CentralMemoryMcp.Functions.Services; public interface IKnowledgeGraphService { diff --git a/CentralMemoryMcp.Functions/Services/RelationService.cs b/CentralMemoryMcp.Functions/Services/RelationService.cs index 4c84ebf..3cc430e 100644 --- a/CentralMemoryMcp.Functions/Services/RelationService.cs +++ b/CentralMemoryMcp.Functions/Services/RelationService.cs @@ -1,8 +1,9 @@ using Azure; using Azure.Data.Tables; using CentralMemoryMcp.Functions.Models; +using CentralMemoryMcp.Functions.Storage; -namespace CentralMemoryMcp.Functions; +namespace CentralMemoryMcp.Functions.Services; public interface IRelationService { @@ -19,7 +20,7 @@ public async Task UpsertRelationAsync(RelationModel model, Cancel { var table = await storage.GetRelationsTableAsync(ct); // Check for existing relation (same workspace, from, to, type) - string filter = $"PartitionKey eq '{model.WorkspaceName}' and FromEntityId eq '{model.FromEntityId.ToString("N")}' and ToEntityId eq '{model.ToEntityId.ToString("N")}' and RelationType eq '{EscapeFilterValue(model.RelationType)}'"; + var filter = $"PartitionKey eq '{model.WorkspaceName}' and FromEntityId eq '{model.FromEntityId:N}' and ToEntityId eq '{model.ToEntityId:N}' and RelationType eq '{EscapeFilterValue(model.RelationType)}'"; await foreach(var e in table.QueryAsync(filter: filter, maxPerPage:1, cancellationToken: ct)) { // Reuse its Id @@ -47,10 +48,34 @@ public async Task UpsertRelationAsync(RelationModel model, Cancel var table = await storage.GetRelationsTableAsync(ct); try { - var partitionKey = workspaceName; - var rowKey = relationId.ToString("N"); - var response = await table.GetEntityAsync(partitionKey, rowKey, cancellationToken: ct); - var e = response.Value; + var response = await table.GetEntityAsync(workspaceName, relationId.ToString("N"), cancellationToken: ct); + var model = new RelationModel( + response.Value.GetString("WorkspaceName")!, + Guid.Parse(response.Value.GetString("FromEntityId")!), + Guid.Parse(response.Value.GetString("ToEntityId")!), + response.Value.GetString("RelationType")!, + response.Value.GetString("Metadata")) + { + Id = relationId + }; + return model; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task> GetRelationsFromEntityAsync(string workspaceName, Guid fromEntityId, CancellationToken ct = default) + { + var table = await storage.GetRelationsTableAsync(ct); + var results = new List(); + var fromIdStr = fromEntityId.ToString("N"); + await foreach (var e in table.QueryAsync( + filter: $"PartitionKey eq '{workspaceName}' and FromEntityId eq '{fromIdStr}'", + cancellationToken: ct)) + { + var relationId = Guid.TryParse(e.GetString("Id"), out var rid) ? rid : Guid.NewGuid(); var model = new RelationModel( e.GetString("WorkspaceName")!, Guid.Parse(e.GetString("FromEntityId")!), @@ -58,6 +83,107 @@ public async Task UpsertRelationAsync(RelationModel model, Cancel e.GetString("RelationType")!, e.GetString("Metadata")); model.Id = relationId; + results.Add(model); + } + return results; + } + + public async Task> GetRelationsForWorkspaceAsync(string workspaceName, CancellationToken ct = default) + { + var table = await storage.GetRelationsTableAsync(ct); + var results = new List(); + await foreach (var e in table.QueryAsync( + filter: $"PartitionKey eq '{workspaceName}'", + cancellationToken: ct)) + { + var relationId = Guid.TryParse(e.GetString("Id"), out var rid) ? rid : Guid.NewGuid(); + var model = new RelationModel( + e.GetString("WorkspaceName")!, + Guid.Parse(e.GetString("FromEntityId")!), + Guid.Parse(e.GetString("ToEntityId")!), + e.GetString("RelationType")!, + e.GetString("Metadata")); + model.Id = relationId; + results.Add(model); + } + return results; + } + + public async Task DeleteRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default) + { + var table = await storage.GetRelationsTableAsync(ct); + try + { + await table.DeleteEntityAsync(workspaceName, relationId.ToString("N"), cancellationToken: ct); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // not found; ignore + } + } + + private static string EscapeFilterValue(string value) => value.Replace("'", "''"); +} +using Azure; +using Azure.Data.Tables; +using CentralMemoryMcp.Functions.Models; +using CentralMemoryMcp.Functions.Storage; + +namespace CentralMemoryMcp.Functions.Services; + +public interface IRelationService +{ + Task UpsertRelationAsync(RelationModel model, CancellationToken ct = default); + Task GetRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default); + Task> GetRelationsFromEntityAsync(string workspaceName, Guid fromEntityId, CancellationToken ct = default); + Task> GetRelationsForWorkspaceAsync(string workspaceName, CancellationToken ct = default); + Task DeleteRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default); +} + +public class RelationService(ITableStorageService storage) : IRelationService +{ + public async Task UpsertRelationAsync(RelationModel model, CancellationToken ct = default) + { + var table = await storage.GetRelationsTableAsync(ct); + // Check for existing relation (same workspace, from, to, type) + var filter = $"PartitionKey eq '{model.WorkspaceName}' and FromEntityId eq '{model.FromEntityId:N}' and ToEntityId eq '{model.ToEntityId:N}' and RelationType eq '{EscapeFilterValue(model.RelationType)}'"; + await foreach(var e in table.QueryAsync(filter: filter, maxPerPage:1, cancellationToken: ct)) + { + // Reuse its Id + if (e.TryGetValue("Id", out var idObj) && idObj is string idStr && Guid.TryParse(idStr, out var existingId)) + { + model.Id = existingId; + } + break; + } + var entity = new TableEntity(model.PartitionKey, model.RowKey) + { + {"Id", model.Id.ToString("N")}, + {"WorkspaceName", model.WorkspaceName}, + {"FromEntityId", model.FromEntityId.ToString("N")}, + {"ToEntityId", model.ToEntityId.ToString("N")}, + {"RelationType", model.RelationType}, + {"Metadata", model.Metadata ?? string.Empty} + }; + await table.UpsertEntityAsync(entity, TableUpdateMode.Replace, ct); + return model; + } + + public async Task GetRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default) + { + var table = await storage.GetRelationsTableAsync(ct); + try + { + var response = await table.GetEntityAsync(workspaceName, relationId.ToString("N"), cancellationToken: ct); + var model = new RelationModel( + response.Value.GetString("WorkspaceName")!, + Guid.Parse(response.Value.GetString("FromEntityId")!), + Guid.Parse(response.Value.GetString("ToEntityId")!), + response.Value.GetString("RelationType")!, + response.Value.GetString("Metadata")) + { + Id = relationId + }; return model; } catch (RequestFailedException ex) when (ex.Status == 404) @@ -112,11 +238,9 @@ public async Task> GetRelationsForWorkspaceAsync(string work public async Task DeleteRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default) { var table = await storage.GetRelationsTableAsync(ct); - var partitionKey = workspaceName; - var rowKey = relationId.ToString("N"); try { - await table.DeleteEntityAsync(partitionKey, rowKey, cancellationToken: ct); + await table.DeleteEntityAsync(workspaceName, relationId.ToString("N"), cancellationToken: ct); } catch (RequestFailedException ex) when (ex.Status == 404) { diff --git a/CentralMemoryMcp.Functions/Storage/TableStorageService.cs b/CentralMemoryMcp.Functions/Storage/TableStorageService.cs index f4736d3..7775bdb 100644 --- a/CentralMemoryMcp.Functions/Storage/TableStorageService.cs +++ b/CentralMemoryMcp.Functions/Storage/TableStorageService.cs @@ -1,7 +1,7 @@ -using Azure; +using Azure.Identity; using Azure.Data.Tables; -namespace CentralMemoryMcp.Functions +namespace CentralMemoryMcp.Functions.Storage { public interface ITableStorageService { @@ -17,7 +17,19 @@ public class TableStorageService : ITableStorageService private const string RelationsTableName = "relations"; private const string WorkspacesTableName = "workspaces"; - public TableStorageService(TableServiceClient serviceClient) => _serviceClient = serviceClient; + public TableStorageService() + { + var conn = Environment.GetEnvironmentVariable("AzureWebJobsStorage"); + if (!string.IsNullOrWhiteSpace(conn)) + { + _serviceClient = new TableServiceClient(conn); + return; + } + + var endpoint = Environment.GetEnvironmentVariable("AzureWebJobsStorage__tableServiceUri") + ?? throw new InvalidOperationException("AzureWebJobsStorage is not configured for managed identity."); + _serviceClient = new TableServiceClient(new Uri(endpoint), new DefaultAzureCredential()); + } public async Task GetEntitiesTableAsync(CancellationToken ct = default) { From 86c77de42ca996a58f1f45172890f5a80993590b Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:36:23 -0500 Subject: [PATCH 3/5] Update version to 0.5.1 (#40) * Update server version to 0.5.1 in host.json * Delete CHANGELOG.md --- CHANGELOG.md | 22 ---------------------- CentralMemoryMcp.Functions/host.json | 4 ++-- 2 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 16763ef..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,22 +0,0 @@ -# Changelog - -## [Unreleased] - -### Changed -- **BREAKING**: MCP tool parameters now use object format instead of JSON strings - - `create_entities`: `entities` parameter now takes single entity object - - `create_relations`: `relations` parameter now takes single relation object - - `update_entity`: `newObservations` and `metadata` parameters now take objects - - `execute_batch_operations`: `operations` parameter now takes object with array - - `merge_entities`: `sourceEntityNames` parameter now takes object with array -- Updated all tool descriptions to reflect object-based parameters -- Added backward compatibility for handlers to support both string and object inputs - -### Fixed -- Fixed "tool parameters array type must have items" validation error -- Improved parameter validation and error handling - -### Documentation -- Updated API.md with new object parameter examples -- Updated README.md with current tool usage patterns -- Added parameter format note to Quick Start section diff --git a/CentralMemoryMcp.Functions/host.json b/CentralMemoryMcp.Functions/host.json index cbdd019..70bfa88 100644 --- a/CentralMemoryMcp.Functions/host.json +++ b/CentralMemoryMcp.Functions/host.json @@ -4,10 +4,10 @@ "mcp": { "instructions": "This server offers LLMs long-term memory storage capabilities about users, projects, and anything else needed. This should not entirely replace using public information tooling like Microsoft Docs or Context7.", "serverName": "Central Memory MCP", - "serverVersion": "1.0.0", + "serverVersion": "0.5.1", "system": { "webhookAuthorizationLevel": "System" } } } -} \ No newline at end of file +} From 99b6b586d9b95bc617975563dce0db3cb6b6d1fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:12:44 -0500 Subject: [PATCH 4/5] Bump Azure.Identity from 1.17.0 to 1.17.1 (#41) --- updated-dependencies: - dependency-name: Azure.Identity dependency-version: 1.17.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj b/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj index 0985715..b36e2ac 100644 --- a/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj +++ b/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj @@ -13,7 +13,7 @@ - + \ No newline at end of file From 83d4834b05067765ffe9c6e53e5399b214048181 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:13:32 -0500 Subject: [PATCH 5/5] Bump Microsoft.Azure.Functions.Worker and Microsoft.Azure.Functions.Worker.Extensions.Mcp (#43) Bumps Microsoft.Azure.Functions.Worker from 2.50.0 to 2.51.0 Bumps Microsoft.Azure.Functions.Worker.Extensions.Mcp from 1.0.0 to 1.1.0 --- updated-dependencies: - dependency-name: Microsoft.Azure.Functions.Worker dependency-version: 2.51.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Mcp dependency-version: 1.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj b/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj index b36e2ac..b1d4ebd 100644 --- a/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj +++ b/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj @@ -8,9 +8,9 @@ CentralMemoryMcp.Functions - + - +