Add REST facade OpenAPI for MCP tools #3
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user