Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 0 additions & 22 deletions CHANGELOG.md

This file was deleted.

6 changes: 3 additions & 3 deletions CentralMemoryMcp.Functions/CentralMemoryMcp.Functions.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
<RootNamespace>CentralMemoryMcp.Functions</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage" Version="6.8.0" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.6" />
</ItemGroup>
</Project>
25 changes: 9 additions & 16 deletions CentralMemoryMcp.Functions/Functions/GraphFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +9 to +11
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The graph parameter is used directly in method calls (lines 20, 51, 76, 81, 119) but is not stored in a field. However, a field _relations is created for the relations parameter at line 11. For consistency, either both should be stored as fields, or neither should be (using parameters directly throughout). The current approach is inconsistent.

Copilot uses AI. Check for mistakes.

[Function(nameof(ReadGraph))]
public async Task<object> ReadGraph(
Expand All @@ -24,7 +17,7 @@ public async Task<object> 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
Expand Down Expand Up @@ -55,7 +48,7 @@ public async Task<object> 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 };
}

Expand All @@ -80,12 +73,12 @@ public async Task<object> 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." };
}

Expand Down Expand Up @@ -116,14 +109,14 @@ public async Task<object> GetEntityRelations(
[McpToolProperty("entityName", "Legacy entity name (used if entityId not provided).", isRequired: false)]
string? entityName)
{
Guid resolvedId = Guid.Empty;
Guid resolvedId;
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The variable resolvedId is declared without initialization. While it's assigned in all code paths before use, C# 14 flow analysis may flag this as potentially unassigned if the compiler cannot prove all paths assign it. Consider initializing to Guid.Empty for clarity: Guid resolvedId = Guid.Empty;

Suggested change
Guid resolvedId;
Guid resolvedId = Guid.Empty;

Copilot uses AI. Check for mistakes.
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}'." };
Expand Down
11 changes: 2 additions & 9 deletions CentralMemoryMcp.Functions/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ITableStorageService, TableStorageService>();
builder.Services.AddSingleton<IKnowledgeGraphService, KnowledgeGraphService>();
Expand Down
3 changes: 2 additions & 1 deletion CentralMemoryMcp.Functions/Services/KnowledgeGraphService.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
142 changes: 133 additions & 9 deletions CentralMemoryMcp.Functions/Services/RelationService.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -19,7 +20,7 @@
{
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)}'";
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter string uses model.WorkspaceName directly without escaping. Workspace names could contain single quotes that would break the OData filter syntax or potentially lead to filter injection. Consider using EscapeFilterValue() for all string values: var filter = $"PartitionKey eq '{EscapeFilterValue(model.WorkspaceName)}' and FromEntityId eq '{model.FromEntityId:N}' and ToEntityId eq '{model.ToEntityId:N}' and RelationType eq '{EscapeFilterValue(model.RelationType)}'";

Copilot uses AI. Check for mistakes.
await foreach(var e in table.QueryAsync<TableEntity>(filter: filter, maxPerPage:1, cancellationToken: ct))
{
// Reuse its Id
Expand Down Expand Up @@ -47,17 +48,142 @@
var table = await storage.GetRelationsTableAsync(ct);
try
{
var partitionKey = workspaceName;
var rowKey = relationId.ToString("N");
var response = await table.GetEntityAsync<TableEntity>(partitionKey, rowKey, cancellationToken: ct);
var e = response.Value;
var response = await table.GetEntityAsync<TableEntity>(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<List<RelationModel>> GetRelationsFromEntityAsync(string workspaceName, Guid fromEntityId, CancellationToken ct = default)
{
var table = await storage.GetRelationsTableAsync(ct);
var results = new List<RelationModel>();
var fromIdStr = fromEntityId.ToString("N");
await foreach (var e in table.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{workspaceName}' and FromEntityId eq '{fromIdStr}'",
Comment on lines +74 to +75
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter string uses model.WorkspaceName and fromIdStr directly without escaping. While GUIDs are safe, workspace names could contain single quotes that would break the OData filter syntax or potentially lead to filter injection. Consider using EscapeFilterValue() for model.WorkspaceName as well: filter: $"PartitionKey eq '{EscapeFilterValue(workspaceName)}' and FromEntityId eq '{fromIdStr}'"

Copilot uses AI. Check for mistakes.
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<List<RelationModel>> GetRelationsForWorkspaceAsync(string workspaceName, CancellationToken ct = default)
{
var table = await storage.GetRelationsTableAsync(ct);
var results = new List<RelationModel>();
await foreach (var e in table.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{workspaceName}'",
Comment on lines +95 to +96
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter string uses workspaceName directly without escaping. Workspace names could contain single quotes that would break the OData filter syntax or potentially lead to filter injection. Consider using EscapeFilterValue() for the workspace name: filter: $"PartitionKey eq '{EscapeFilterValue(workspaceName)}'"

Copilot uses AI. Check for mistakes.
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;

Check failure on line 127 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations

Check failure on line 127 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations
using Azure.Data.Tables;

Check failure on line 128 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations

Check failure on line 128 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations
using CentralMemoryMcp.Functions.Models;

Check failure on line 129 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations

Check failure on line 129 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations
using CentralMemoryMcp.Functions.Storage;

Check failure on line 130 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations

Check failure on line 130 in CentralMemoryMcp.Functions/Services/RelationService.cs

View workflow job for this annotation

GitHub Actions / build-and-deploy

A using clause must precede all other elements defined in the namespace except extern alias declarations

namespace CentralMemoryMcp.Functions.Services;

public interface IRelationService
{
Task<RelationModel> UpsertRelationAsync(RelationModel model, CancellationToken ct = default);
Task<RelationModel?> GetRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default);
Task<List<RelationModel>> GetRelationsFromEntityAsync(string workspaceName, Guid fromEntityId, CancellationToken ct = default);
Task<List<RelationModel>> GetRelationsForWorkspaceAsync(string workspaceName, CancellationToken ct = default);
Task DeleteRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default);
}

public class RelationService(ITableStorageService storage) : IRelationService
{
public async Task<RelationModel> 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<TableEntity>(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<RelationModel?> GetRelationAsync(string workspaceName, Guid relationId, CancellationToken ct = default)
{
var table = await storage.GetRelationsTableAsync(ct);
try
{
var response = await table.GetEntityAsync<TableEntity>(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)
Expand Down Expand Up @@ -112,11 +238,9 @@
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)
{
Expand Down
18 changes: 15 additions & 3 deletions CentralMemoryMcp.Functions/Storage/TableStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using Azure;
using Azure.Identity;
using Azure.Data.Tables;

namespace CentralMemoryMcp.Functions
namespace CentralMemoryMcp.Functions.Storage
{
public interface ITableStorageService
{
Expand All @@ -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<TableClient> GetEntitiesTableAsync(CancellationToken ct = default)
{
Expand Down
4 changes: 2 additions & 2 deletions CentralMemoryMcp.Functions/host.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The server version is being downgraded from "1.0.0" to "0.5.1", which seems unusual. Typically, versions move forward, not backward. This could cause confusion for API consumers and violate semantic versioning expectations. If this is intentional to reflect a pre-release state, please verify this is correct. Otherwise, consider versioning forward (e.g., "1.0.1" or "1.1.0").

Suggested change
"serverVersion": "0.5.1",
"serverVersion": "1.0.1",

Copilot uses AI. Check for mistakes.
"system": {
"webhookAuthorizationLevel": "System"
}
}
}
}
}
Loading