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" 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/CentralMemoryMcp.Functions.csproj b/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj index 0985715..b1d4ebd 100644 --- a/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj +++ b/CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj @@ -8,12 +8,12 @@ CentralMemoryMcp.Functions - + - + - + \ No newline at end of file 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) { 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 +}