10 changed files with 700 additions and 4 deletions
+9 -1
View File
@@ -13,6 +13,7 @@ The project is intentionally small and operationally explicit: everything local
- Updates the default embedding whenever memory content changes.
- Soft-deletes items so accidental deletes are recoverable at the database level.
- Exposes everything through HTTP MCP tools.
- Exposes a generated REST-compatible tool facade with OpenAPI metadata.
- Provides an optional Blazor web UI for browsing and testing memories.
## Stack
@@ -36,7 +37,8 @@ The project is intentionally small and operationally explicit: everything local
| Cortex app | <http://localhost:5117> | <http://localhost:5217> |
| Web UI | <http://localhost:5118> | <http://localhost:5218> |
| MCP endpoint | <http://localhost:5117/mcp> | <http://localhost:5217/mcp> |
| PostgreSQL | `localhost:54329` | `localhost:54339` |
| OpenAPI document | <http://localhost:5117/openapi/v1.json> | <http://localhost:5217/openapi/v1.json> |
| PostgreSQL | `localhost:54329` | `localhost:55439` |
| Embeddings | <http://localhost:8088> | <http://localhost:8188> |
## Quick Start
@@ -71,6 +73,12 @@ Connect an MCP client to:
http://localhost:5117/mcp
```
Inspect the generated OpenAPI document:
```text
http://localhost:5117/openapi/v1.json
```
## Scripts
All local operations are script-first.
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_USER: cortex
POSTGRES_PASSWORD: cortex_dev_password
ports:
- "54339:5432"
- "55439:5432"
volumes:
- cortex-test-postgres-data:/var/lib/postgresql/data
healthcheck:
+18
View File
@@ -6,6 +6,24 @@ Cortex exposes MCP tools over:
http://localhost:5117/mcp
```
The same tool surface is also available through a generated REST-compatible facade:
```text
POST http://localhost:5117/api/tools/{ToolName}
```
The OpenAPI document for that facade is generated from the MCP tool metadata:
```text
http://localhost:5117/openapi/v1.json
```
Swagger-compatible tooling can also read:
```text
http://localhost:5117/swagger/v1/swagger.json
```
The isolated test endpoint is:
```text
+1 -1
View File
@@ -16,7 +16,7 @@ The test stack is isolated from the dev stack.
| Compose project | `cortex-test` |
| App | <http://localhost:5217> |
| MCP endpoint | <http://localhost:5217/mcp> |
| PostgreSQL | `localhost:54339` |
| PostgreSQL | `localhost:55439` |
| Embeddings | <http://localhost:8188> |
## Full Integration Pipeline
+1 -1
View File
@@ -25,7 +25,7 @@ Dev ports:
Test ports:
- `5217`
- `54339`
- `55439`
- `8188`
If a port is already used, stop the conflicting process or change the compose file ports.
@@ -0,0 +1,99 @@
using System.ComponentModel;
using System.Reflection;
using Cortex.Core.Services;
using ModelContextProtocol.Server;
namespace Cortex.Api.OpenApi;
public sealed class CortexToolCatalog
{
private readonly Dictionary<string, CortexToolDescriptor> _tools;
private CortexToolCatalog(IEnumerable<CortexToolDescriptor> tools)
{
_tools = tools.ToDictionary(tool => tool.Name, StringComparer.Ordinal);
}
public IReadOnlyCollection<CortexToolDescriptor> Tools => _tools.Values;
public static CortexToolCatalog FromAssembly(Assembly assembly)
{
var tools = assembly
.GetTypes()
.Where(type => type.GetCustomAttribute<McpServerToolTypeAttribute>() is not null)
.SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static))
.Select(method => (Method: method, Attribute: method.GetCustomAttribute<McpServerToolAttribute>()))
.Where(candidate => candidate.Attribute is not null)
.Select(candidate => CreateDescriptor(candidate.Method, candidate.Attribute!))
.OrderBy(tool => tool.Name, StringComparer.Ordinal)
.ToArray();
return new CortexToolCatalog(tools);
}
public bool TryGetTool(string name, out CortexToolDescriptor tool)
{
return _tools.TryGetValue(name, out tool!);
}
private static CortexToolDescriptor CreateDescriptor(MethodInfo method, McpServerToolAttribute attribute)
{
var parameters = method
.GetParameters()
.Select(parameter => new CortexToolParameterDescriptor(
parameter,
IsInfrastructureParameter(parameter),
parameter.GetCustomAttribute<DescriptionAttribute>()?.Description))
.ToArray();
return new CortexToolDescriptor(
attribute.Name ?? method.Name,
method.GetCustomAttribute<DescriptionAttribute>()?.Description,
attribute.ReadOnly,
attribute.Destructive,
method,
UnwrapReturnType(method.ReturnType),
parameters);
}
private static bool IsInfrastructureParameter(ParameterInfo parameter)
{
return parameter.ParameterType == typeof(CancellationToken)
|| parameter.ParameterType == typeof(ICortexMemoryService);
}
private static Type UnwrapReturnType(Type returnType)
{
if (returnType.IsGenericType
&& (returnType.GetGenericTypeDefinition() == typeof(Task<>)
|| returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
{
return returnType.GetGenericArguments()[0];
}
return returnType;
}
}
public sealed record CortexToolDescriptor(
string Name,
string? Description,
bool ReadOnly,
bool Destructive,
MethodInfo Method,
Type ReturnType,
IReadOnlyList<CortexToolParameterDescriptor> Parameters)
{
public IEnumerable<CortexToolParameterDescriptor> ToolParameters => Parameters.Where(parameter => !parameter.IsInfrastructure);
}
public sealed record CortexToolParameterDescriptor(
ParameterInfo Parameter,
bool IsInfrastructure,
string? Description)
{
public string Name => Parameter.Name ?? throw new InvalidOperationException("Tool parameter is missing a name.");
public Type Type => Parameter.ParameterType;
public bool HasDefaultValue => Parameter.HasDefaultValue;
public object? DefaultValue => Parameter.DefaultValue;
}
@@ -0,0 +1,328 @@
using System.Collections;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Cortex.Api.OpenApi;
public static class CortexToolOpenApiDocument
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
public static JsonObject Create(CortexToolCatalog catalog, HttpRequest request)
{
var components = new JsonObject();
var schemaGenerator = new OpenApiSchemaGenerator(components);
var paths = new JsonObject();
foreach (var tool in catalog.Tools)
{
paths[$"/api/tools/{tool.Name}"] = CreateToolPath(tool, schemaGenerator);
}
return new JsonObject
{
["openapi"] = "3.0.3",
["info"] = new JsonObject
{
["title"] = "Cortex Tool API",
["version"] = "v1",
["description"] = "REST-compatible HTTP facade generated from the Cortex MCP tool surface."
},
["servers"] = new JsonArray
{
new JsonObject
{
["url"] = $"{request.Scheme}://{request.Host}"
}
},
["paths"] = paths,
["components"] = new JsonObject
{
["schemas"] = components
}
};
}
private static JsonObject CreateToolPath(CortexToolDescriptor tool, OpenApiSchemaGenerator schemaGenerator)
{
var responseSchema = schemaGenerator.SchemaFor(tool.ReturnType);
var operation = new JsonObject
{
["operationId"] = tool.Name,
["summary"] = tool.Name,
["description"] = tool.Description,
["tags"] = new JsonArray("Cortex Tools"),
["x-mcp-readOnly"] = tool.ReadOnly,
["x-mcp-destructive"] = tool.Destructive,
["responses"] = new JsonObject
{
["200"] = new JsonObject
{
["description"] = "Tool result.",
["content"] = new JsonObject
{
["application/json"] = new JsonObject
{
["schema"] = responseSchema
}
}
},
["400"] = CreateProblemResponse("Invalid request."),
["404"] = CreateProblemResponse("Tool was not found."),
["409"] = CreateProblemResponse("Tool operation could not be completed."),
["500"] = CreateProblemResponse("Unexpected server error.")
}
};
var requestSchema = CreateRequestSchema(tool, schemaGenerator);
if (requestSchema is not null)
{
operation["requestBody"] = new JsonObject
{
["required"] = true,
["content"] = new JsonObject
{
["application/json"] = new JsonObject
{
["schema"] = requestSchema
}
}
};
}
return new JsonObject
{
["post"] = operation
};
}
private static JsonObject? CreateRequestSchema(CortexToolDescriptor tool, OpenApiSchemaGenerator schemaGenerator)
{
var properties = new JsonObject();
var required = new JsonArray();
foreach (var parameter in tool.ToolParameters)
{
var schema = schemaGenerator.SchemaFor(parameter.Type);
if (!string.IsNullOrWhiteSpace(parameter.Description))
{
schema["description"] = parameter.Description;
}
if (parameter.HasDefaultValue && parameter.DefaultValue is not null)
{
schema["default"] = JsonSerializer.SerializeToNode(parameter.DefaultValue, JsonOptions);
}
properties[parameter.Name] = schema;
if (!parameter.HasDefaultValue)
{
required.Add(parameter.Name);
}
}
if (properties.Count == 0)
{
return null;
}
var schemaObject = new JsonObject
{
["type"] = "object",
["additionalProperties"] = false,
["properties"] = properties
};
if (required.Count > 0)
{
schemaObject["required"] = required;
}
return schemaObject;
}
private static JsonObject CreateProblemResponse(string description)
{
return new JsonObject
{
["description"] = description,
["content"] = new JsonObject
{
["application/problem+json"] = new JsonObject
{
["schema"] = new JsonObject
{
["type"] = "object"
}
}
}
};
}
private sealed class OpenApiSchemaGenerator(JsonObject components)
{
private readonly NullabilityInfoContext _nullability = new();
public JsonObject SchemaFor(Type type)
{
var actualType = Nullable.GetUnderlyingType(type) ?? type;
var schema = SchemaForNonNullable(actualType);
if (Nullable.GetUnderlyingType(type) is not null)
{
schema["nullable"] = true;
}
return schema;
}
private JsonObject SchemaForNonNullable(Type type)
{
if (type == typeof(string))
{
return new JsonObject { ["type"] = "string" };
}
if (type == typeof(Guid))
{
return new JsonObject { ["type"] = "string", ["format"] = "uuid" };
}
if (type == typeof(DateTimeOffset) || type == typeof(DateTime))
{
return new JsonObject { ["type"] = "string", ["format"] = "date-time" };
}
if (type == typeof(bool))
{
return new JsonObject { ["type"] = "boolean" };
}
if (type == typeof(int) || type == typeof(long) || type == typeof(short))
{
return new JsonObject { ["type"] = "integer" };
}
if (type == typeof(double) || type == typeof(float) || type == typeof(decimal))
{
return new JsonObject { ["type"] = "number" };
}
if (type.IsEnum)
{
var values = new JsonArray();
foreach (var value in Enum.GetNames(type))
{
values.Add(value);
}
return new JsonObject
{
["type"] = "string",
["enum"] = values
};
}
if (TryGetEnumerableElementType(type, out var elementType))
{
return new JsonObject
{
["type"] = "array",
["items"] = SchemaFor(elementType)
};
}
return ReferenceSchemaFor(type);
}
private JsonObject ReferenceSchemaFor(Type type)
{
var schemaName = type.Name;
if (!components.ContainsKey(schemaName))
{
components[schemaName] = CreateObjectSchema(type);
}
return new JsonObject
{
["$ref"] = $"#/components/schemas/{schemaName}"
};
}
private JsonObject CreateObjectSchema(Type type)
{
var properties = new JsonObject();
var required = new JsonArray();
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (property.GetIndexParameters().Length > 0)
{
continue;
}
var propertySchema = SchemaFor(property.PropertyType);
var propertyName = JsonNamingPolicy.CamelCase.ConvertName(property.Name);
properties[propertyName] = propertySchema;
var nullability = _nullability.Create(property);
if (property.PropertyType.IsValueType && Nullable.GetUnderlyingType(property.PropertyType) is null
|| nullability.ReadState == NullabilityState.NotNull)
{
required.Add(propertyName);
}
if (!property.PropertyType.IsValueType && nullability.ReadState == NullabilityState.Nullable)
{
propertySchema["nullable"] = true;
}
}
var schema = new JsonObject
{
["type"] = "object",
["additionalProperties"] = false,
["properties"] = properties
};
if (required.Count > 0)
{
schema["required"] = required;
}
return schema;
}
private static bool TryGetEnumerableElementType(Type type, out Type elementType)
{
if (type.IsArray)
{
elementType = type.GetElementType()!;
return true;
}
var enumerableType = type == typeof(IEnumerable)
? null
: type.GetInterfaces()
.Append(type)
.FirstOrDefault(candidate => candidate.IsGenericType
&& candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>));
if (enumerableType is null)
{
elementType = null!;
return false;
}
elementType = enumerableType.GetGenericArguments()[0];
return true;
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace Cortex.Api.OpenApi;
public static class CortexToolRestApi
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
PropertyNameCaseInsensitive = true
};
public static void MapCortexToolRestApi(this WebApplication app)
{
app.MapGet("/openapi/v1.json", (CortexToolCatalog catalog, HttpRequest request) =>
Results.Json(CortexToolOpenApiDocument.Create(catalog, request), JsonOptions));
app.MapGet("/swagger/v1/swagger.json", (CortexToolCatalog catalog, HttpRequest request) =>
Results.Json(CortexToolOpenApiDocument.Create(catalog, request), JsonOptions));
app.MapPost("/api/tools/{toolName}", InvokeToolAsync);
}
private static async Task<IResult> InvokeToolAsync(
string toolName,
HttpContext httpContext,
CortexToolCatalog catalog,
CancellationToken cancellationToken)
{
if (!catalog.TryGetTool(toolName, out var tool))
{
return Results.NotFound(new ProblemDetails
{
Title = "Tool not found",
Detail = $"No Cortex tool named '{toolName}' is registered.",
Status = StatusCodes.Status404NotFound
});
}
JsonObject? body;
try
{
body = await ReadRequestBodyAsync(httpContext.Request, cancellationToken);
}
catch (JsonException ex)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid JSON request body",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
try
{
var arguments = BuildArguments(tool, body, httpContext.RequestServices, cancellationToken);
var result = tool.Method.Invoke(null, arguments);
var value = await AwaitResultAsync(result);
return Results.Json(value, JsonOptions);
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
return ToolFailure(ex.InnerException);
}
catch (Exception ex)
{
return ToolFailure(ex);
}
}
private static async Task<JsonObject?> ReadRequestBodyAsync(HttpRequest request, CancellationToken cancellationToken)
{
if (request.ContentLength == 0)
{
return null;
}
var body = await JsonSerializer.DeserializeAsync<JsonObject>(
request.Body,
JsonOptions,
cancellationToken);
return body;
}
private static object?[] BuildArguments(
CortexToolDescriptor tool,
JsonObject? body,
IServiceProvider services,
CancellationToken cancellationToken)
{
var arguments = new object?[tool.Parameters.Count];
for (var index = 0; index < tool.Parameters.Count; index++)
{
var parameter = tool.Parameters[index];
if (parameter.Parameter.ParameterType == typeof(CancellationToken))
{
arguments[index] = cancellationToken;
continue;
}
if (parameter.IsInfrastructure)
{
arguments[index] = services.GetRequiredService(parameter.Parameter.ParameterType);
continue;
}
if (body is not null && body.TryGetPropertyValue(parameter.Name, out var node))
{
arguments[index] = node is null
? null
: node.Deserialize(parameter.Type, JsonOptions);
continue;
}
if (parameter.HasDefaultValue)
{
arguments[index] = parameter.DefaultValue;
continue;
}
throw new ArgumentException($"Required parameter '{parameter.Name}' is missing.");
}
return arguments;
}
private static async Task<object?> AwaitResultAsync(object? result)
{
if (result is null)
{
return null;
}
if (result is Task task)
{
await task;
var resultProperty = task.GetType().GetProperty("Result", BindingFlags.Instance | BindingFlags.Public);
return resultProperty?.GetValue(task);
}
return result;
}
private static IResult ToolFailure(Exception exception)
{
return exception switch
{
ArgumentException => Results.BadRequest(CreateProblem(exception, StatusCodes.Status400BadRequest)),
InvalidOperationException => Results.Conflict(CreateProblem(exception, StatusCodes.Status409Conflict)),
_ => Results.Problem(
title: "Tool invocation failed",
detail: exception.Message,
statusCode: StatusCodes.Status500InternalServerError)
};
}
private static ProblemDetails CreateProblem(Exception exception, int statusCode)
{
return new ProblemDetails
{
Title = exception.GetType().Name,
Detail = exception.Message,
Status = statusCode
};
}
}
+9
View File
@@ -1,3 +1,6 @@
using System.Text.Json.Serialization;
using Cortex.Api.OpenApi;
using Cortex.Api.Tools;
using Cortex.Core.Data;
using Cortex.Core.Options;
using Cortex.Core.Services;
@@ -25,6 +28,11 @@ builder.Services.AddHttpClient<IEmbeddingClient, TeiEmbeddingClient>((services,
});
builder.Services.AddScoped<ICortexMemoryService, CortexMemoryService>();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSingleton(CortexToolCatalog.FromAssembly(typeof(CortexTools).Assembly));
builder.Services
.AddMcpServer()
@@ -58,6 +66,7 @@ app.MapGet("/health", () => Results.Ok(new
status = "ok",
utc = DateTimeOffset.UtcNow
}));
app.MapCortexToolRestApi();
app.MapMcp("/mcp");
app.Run();
@@ -1,4 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using Cortex.IntegrationTests.Fixtures;
using Cortex.IntegrationTests.Support;
using Xunit;
namespace Cortex.IntegrationTests.Tests;
@@ -7,6 +10,8 @@ namespace Cortex.IntegrationTests.Tests;
[Trait("Category", "Integration")]
public sealed class SmokeTests(CortexTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task Health_endpoint_is_available()
{
@@ -41,4 +46,58 @@ public sealed class SmokeTests(CortexTestFixture fixture)
Assert.Contains("UpdateNote", names);
Assert.Contains("DeleteItem", names);
}
[Fact]
public async Task OpenApi_document_describes_mcp_tools_as_rest_operations()
{
using var http = new HttpClient();
using var response = await http.GetAsync(new Uri(fixture.BaseUrl, "/openapi/v1.json"));
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = document.RootElement;
var paths = root.GetProperty("paths");
var createProject = paths.GetProperty("/api/tools/CreateProject").GetProperty("post");
var requestProperties = createProject
.GetProperty("requestBody")
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema")
.GetProperty("properties");
Assert.Equal("3.0.3", root.GetProperty("openapi").GetString());
Assert.Equal("CreateProject", createProject.GetProperty("operationId").GetString());
Assert.True(requestProperties.TryGetProperty("name", out _));
Assert.True(paths.TryGetProperty("/api/tools/FindItems", out _));
}
[Fact]
public async Task Rest_tool_facade_invokes_mcp_tool_methods()
{
using var http = new HttpClient();
var projectName = fixture.NewProjectName("rest");
using var createResponse = await http.PostAsJsonAsync(
new Uri(fixture.BaseUrl, "/api/tools/CreateProject"),
new { name = projectName },
JsonOptions);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<ProjectDto>(JsonOptions);
Assert.NotNull(created);
Assert.Equal(projectName, created.Name);
using var listResponse = await http.PostAsJsonAsync(
new Uri(fixture.BaseUrl, "/api/tools/ListProjects"),
new { },
JsonOptions);
listResponse.EnsureSuccessStatusCode();
var projects = await listResponse.Content.ReadFromJsonAsync<ProjectDto[]>(JsonOptions);
Assert.NotNull(projects);
Assert.Contains(projects, project => project.Id == created.Id);
}
}