Protocol for MCP handler revised.

This commit is contained in:
2026-03-07 19:32:19 -05:00
parent 862eb697cb
commit 4befd070db
5 changed files with 176 additions and 368 deletions

View File

@@ -593,3 +593,82 @@ public:
Result->SetArrayField(TEXT("properties"), PropList);
}
};
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
UCLASS(meta=(ToolName="show_commands"))
class UMCPHandler_ShowCommands : public UObject, public IMCPHandler
{
GENERATED_BODY()
public:
virtual FString GetDescription() const override
{
return TEXT("List all available commands with their descriptions.");
}
virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override
{
TArray<TSharedPtr<FJsonValue>> CommandsArray;
for (TObjectIterator<UClass> It; It; ++It)
{
UClass* Class = *It;
if (Class->HasAnyClassFlags(CLASS_Abstract)) continue;
const IMCPHandler* Handler = Cast<IMCPHandler>(Class->GetDefaultObject());
if (!Handler) continue;
const FString& ToolName = Class->GetMetaData(TEXT("ToolName"));
if (ToolName.IsEmpty()) continue;
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("command"), ToolName);
Entry->SetStringField(TEXT("description"), Handler->GetDescription());
// Document parameters from UPROPERTY fields
TArray<TSharedPtr<FJsonValue>> ParamsArray;
for (TFieldIterator<FProperty> PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt)
{
FProperty* Prop = *PropIt;
TSharedRef<FJsonObject> ParamObj = MakeShared<FJsonObject>();
ParamObj->SetStringField(TEXT("name"), MCPUtils::PropertyNameToJsonKey(Prop->GetName()));
ParamObj->SetBoolField(TEXT("required"), !Prop->HasMetaData(TEXT("Optional")));
// Type
if (CastField<FStrProperty>(Prop))
ParamObj->SetStringField(TEXT("type"), TEXT("string"));
else if (CastField<FIntProperty>(Prop))
ParamObj->SetStringField(TEXT("type"), TEXT("integer"));
else if (CastField<FFloatProperty>(Prop) || CastField<FDoubleProperty>(Prop))
ParamObj->SetStringField(TEXT("type"), TEXT("number"));
else if (CastField<FBoolProperty>(Prop))
ParamObj->SetStringField(TEXT("type"), TEXT("boolean"));
else if (FStructProperty* SP = CastField<FStructProperty>(Prop))
{
FString StructName = SP->Struct->GetName();
StructName.ReplaceInline(TEXT("MCP"), TEXT(""));
ParamObj->SetStringField(TEXT("type"), StructName);
}
else
ParamObj->SetStringField(TEXT("type"), TEXT("string"));
// Description from metadata
const FString& Desc = Prop->GetMetaData(TEXT("Description"));
if (!Desc.IsEmpty())
{
ParamObj->SetStringField(TEXT("description"), Desc);
}
ParamsArray.Add(MakeShared<FJsonValueObject>(ParamObj));
}
if (ParamsArray.Num() > 0)
{
Entry->SetArrayField(TEXT("parameters"), ParamsArray);
}
CommandsArray.Add(MakeShared<FJsonValueObject>(Entry));
}
Result->SetNumberField(TEXT("count"), CommandsArray.Num());
Result->SetArrayField(TEXT("commands"), CommandsArray);
}
};

View File

@@ -248,229 +248,6 @@ int32 TryAddMaterialExpressionSEH(
#endif // PLATFORM_WINDOWS
// ============================================================
// JSON-RPC helpers
// ============================================================
FString FMCPServer::MakeJsonRpcResult(int32 Id, TSharedPtr<FJsonObject> Result)
{
TSharedRef<FJsonObject> Response = MakeShared<FJsonObject>();
Response->SetStringField(TEXT("jsonrpc"), TEXT("2.0"));
Response->SetNumberField(TEXT("id"), Id);
Response->SetObjectField(TEXT("result"), Result.IsValid() ? Result.ToSharedRef() : MakeShared<FJsonObject>());
return MCPUtils::JsonToString(Response);
}
FString FMCPServer::MakeJsonRpcToolResult(int32 Id, TSharedPtr<FJsonObject> ToolResult, bool bIsError)
{
TSharedRef<FJsonObject> ContentItem = MakeShared<FJsonObject>();
ContentItem->SetStringField(TEXT("type"), TEXT("text"));
ContentItem->SetStringField(TEXT("text"), ToolResult.IsValid() ? MCPUtils::JsonToString(ToolResult.ToSharedRef()) : TEXT("{}"));
TArray<TSharedPtr<FJsonValue>> ContentArray;
ContentArray.Add(MakeShared<FJsonValueObject>(ContentItem));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetArrayField(TEXT("content"), ContentArray);
if (bIsError)
{
Result->SetBoolField(TEXT("isError"), true);
}
return MakeJsonRpcResult(Id, Result);
}
FString FMCPServer::MakeJsonRpcError(int32 Id, int32 Code, const FString& Message)
{
TSharedRef<FJsonObject> ErrObj = MakeShared<FJsonObject>();
ErrObj->SetNumberField(TEXT("code"), Code);
ErrObj->SetStringField(TEXT("message"), Message);
TSharedRef<FJsonObject> Response = MakeShared<FJsonObject>();
Response->SetStringField(TEXT("jsonrpc"), TEXT("2.0"));
Response->SetNumberField(TEXT("id"), Id);
Response->SetObjectField(TEXT("error"), ErrObj);
return MCPUtils::JsonToString(Response);
}
// ============================================================
// The Cached Tools List
// ============================================================
TSharedRef<FJsonObject> FMCPServer::FPropertyToPropSchema(FProperty* Prop)
{
TSharedRef<FJsonObject> Schema = MakeShared<FJsonObject>();
if (CastField<FStrProperty>(Prop))
{
Schema->SetStringField(TEXT("type"), TEXT("string"));
}
else if (CastField<FIntProperty>(Prop))
{
Schema->SetStringField(TEXT("type"), TEXT("integer"));
}
else if (CastField<FFloatProperty>(Prop) || CastField<FDoubleProperty>(Prop))
{
Schema->SetStringField(TEXT("type"), TEXT("number"));
}
else if (CastField<FBoolProperty>(Prop))
{
Schema->SetStringField(TEXT("type"), TEXT("boolean"));
}
else if (FStructProperty* StructProp = CastField<FStructProperty>(Prop))
{
if (StructProp->Struct == FMCPJsonArray::StaticStruct())
{
Schema->SetStringField(TEXT("type"), TEXT("array"));
}
else
{
Schema->SetStringField(TEXT("type"), TEXT("object"));
}
}
else if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Prop))
{
Schema->SetStringField(TEXT("type"), TEXT("string"));
UEnum* Enum = EnumProp->GetEnum();
TArray<TSharedPtr<FJsonValue>> EnumValues;
for (int32 i = 0; i < Enum->NumEnums() - 1; ++i)
{
EnumValues.Add(MakeShared<FJsonValueString>(Enum->GetNameStringByIndex(i)));
}
Schema->SetArrayField(TEXT("enum"), EnumValues);
}
else if (FByteProperty* ByteProp = CastField<FByteProperty>(Prop))
{
if (ByteProp->Enum)
{
Schema->SetStringField(TEXT("type"), TEXT("string"));
TArray<TSharedPtr<FJsonValue>> EnumValues;
for (int32 i = 0; i < ByteProp->Enum->NumEnums() - 1; ++i)
{
EnumValues.Add(MakeShared<FJsonValueString>(ByteProp->Enum->GetNameStringByIndex(i)));
}
Schema->SetArrayField(TEXT("enum"), EnumValues);
}
else
{
Schema->SetStringField(TEXT("type"), TEXT("integer"));
}
}
else
{
Schema->SetStringField(TEXT("type"), TEXT("string"));
}
return Schema;
}
TSharedRef<FJsonObject> FMCPServer::HandlerClassToToolSchema(UClass* HandlerClass)
{
TSharedRef<FJsonObject> Tool = MakeShared<FJsonObject>();
Tool->SetStringField(TEXT("name"), HandlerClass->GetMetaData(TEXT("ToolName")));
// Get description from the handler
UObject* TempObj = NewObject<UObject>(GetTransientPackage(), HandlerClass);
IMCPHandler* Handler = Cast<IMCPHandler>(TempObj);
if (Handler)
{
Tool->SetStringField(TEXT("description"), Handler->GetDescription());
}
TempObj->MarkAsGarbage();
// Build input schema from UPROPERTY fields
TSharedRef<FJsonObject> InputSchema = MakeShared<FJsonObject>();
InputSchema->SetStringField(TEXT("type"), TEXT("object"));
TSharedRef<FJsonObject> Properties = MakeShared<FJsonObject>();
TArray<TSharedPtr<FJsonValue>> RequiredArray;
for (TFieldIterator<FProperty> It(HandlerClass, EFieldIterationFlags::None); It; ++It)
{
FProperty* Prop = *It;
FString JsonKey = MCPUtils::PropertyNameToJsonKey(Prop->GetName());
Properties->SetObjectField(JsonKey, FPropertyToPropSchema(Prop));
if (!Prop->HasMetaData(TEXT("Optional")))
{
RequiredArray.Add(MakeShared<FJsonValueString>(JsonKey));
}
}
InputSchema->SetObjectField(TEXT("properties"), Properties);
if (RequiredArray.Num() > 0)
{
InputSchema->SetArrayField(TEXT("required"), RequiredArray);
}
Tool->SetObjectField(TEXT("inputSchema"), InputSchema);
return Tool;
}
void FMCPServer::BuildCachedToolsList()
{
TArray<TSharedPtr<FJsonValue>> ToolsArray;
// New-style handlers: have UPROPERTY metadata for parameter schemas
for (const auto& KV : MCPHandlerRegistry)
{
ToolsArray.Add(MakeShared<FJsonValueObject>(HandlerClassToToolSchema(KV.Value)));
}
CachedToolsList = MakeShared<FJsonObject>();
CachedToolsList->SetArrayField(TEXT("tools"), ToolsArray);
}
// ============================================================
// Handlers for MCP Methods
// ============================================================
FString FMCPServer::HandleInitialize(int32 Id, const FJsonObject* Params)
{
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("protocolVersion"), TEXT("2024-11-05"));
TSharedRef<FJsonObject> Capabilities = MakeShared<FJsonObject>();
TSharedRef<FJsonObject> ToolsCap = MakeShared<FJsonObject>();
Capabilities->SetObjectField(TEXT("tools"), ToolsCap);
Result->SetObjectField(TEXT("capabilities"), Capabilities);
TSharedRef<FJsonObject> ServerInfo = MakeShared<FJsonObject>();
ServerInfo->SetStringField(TEXT("name"), TEXT("BlueprintMCP"));
ServerInfo->SetStringField(TEXT("version"), TEXT("1.0.0"));
Result->SetObjectField(TEXT("serverInfo"), ServerInfo);
return MakeJsonRpcResult(Id, Result);
}
FString FMCPServer::HandleToolsList(int32 Id)
{
if (!CachedToolsList.IsValid())
{
BuildCachedToolsList();
}
return MakeJsonRpcResult(Id, CachedToolsList);
}
FString FMCPServer::HandleToolsCall(int32 Id, const FJsonObject* Params)
{
FString ToolName;
if (!Params->TryGetStringField(TEXT("name"), ToolName))
{
return MakeJsonRpcError(Id, -32602, TEXT("Missing 'name' in tools/call params"));
}
const TSharedPtr<FJsonObject>* ArgsPtr = nullptr;
Params->TryGetObjectField(TEXT("arguments"), ArgsPtr);
TSharedPtr<FJsonObject> Args = ArgsPtr ? *ArgsPtr : MakeShared<FJsonObject>();
TSharedRef<FJsonObject> ToolResult = MakeShared<FJsonObject>();
DispatchToolCall(ToolName, Args.Get(), &*ToolResult);
bool bIsError = ToolResult->HasField(TEXT("error"));
return MakeJsonRpcToolResult(Id, ToolResult, bIsError);
}
void FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result)
{
@@ -507,59 +284,31 @@ void FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Pa
}
// ============================================================
// HandleJsonRpc — parse a JSON-RPC line and dispatch
// HandleRequest — parse a JSON command and dispatch
// ============================================================
FString FMCPServer::HandleJsonRpc(const FString& Line)
FString FMCPServer::HandleRequest(const FString& Line)
{
TSharedPtr<FJsonObject> Request = MCPUtils::ParseBodyJson(Line);
if (!Request.IsValid())
{
return MakeJsonRpcError(0, -32700, TEXT("Parse error"));
TSharedRef<FJsonObject> ErrResult = MakeShared<FJsonObject>();
MCPUtils::MakeErrorJson(&*ErrResult, TEXT("JSON parse error"));
return MCPUtils::JsonToString(ErrResult);
}
FString Method;
if (!Request->TryGetStringField(TEXT("method"), Method))
FString Command;
if (!Request->TryGetStringField(TEXT("command"), Command))
{
return MakeJsonRpcError(0, -32600, TEXT("Missing 'method' field"));
TSharedRef<FJsonObject> ErrResult = MakeShared<FJsonObject>();
MCPUtils::MakeErrorJson(&*ErrResult, TEXT("Missing 'command' field"));
return MCPUtils::JsonToString(ErrResult);
}
Request->RemoveField(TEXT("command"));
// Notifications (no id) — we just ignore them
int32 Id = 0;
bool bIsNotification = !Request->HasField(TEXT("id"));
if (!bIsNotification)
{
Id = (int32)Request->GetNumberField(TEXT("id"));
}
const TSharedPtr<FJsonObject>* ParamsPtr = nullptr;
Request->TryGetObjectField(TEXT("params"), ParamsPtr);
const FJsonObject* Params = ParamsPtr ? ParamsPtr->Get() : nullptr;
// Route MCP methods
if (Method == TEXT("initialize"))
{
return HandleInitialize(Id, Params);
}
if (Method == TEXT("notifications/initialized"))
{
return FString(); // notification, no response
}
if (Method == TEXT("tools/list"))
{
return HandleToolsList(Id);
}
if (Method == TEXT("tools/call"))
{
if (!Params)
{
return MakeJsonRpcError(Id, -32602, TEXT("Missing params for tools/call"));
}
return HandleToolsCall(Id, Params);
}
// Unknown method
return MakeJsonRpcError(Id, -32601, FString::Printf(TEXT("Method not found: %s"), *Method));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
DispatchToolCall(Command, Request.Get(), &*Result);
return MCPUtils::JsonToString(Result);
}
// ============================================================
@@ -785,7 +534,7 @@ bool FMCPServer::ProcessOneRequest()
if (!Msg.IsValid()) return false;
// Process on game thread
FString Response = HandleJsonRpc(Msg->Line);
FString Response = HandleRequest(Msg->Line);
Msg->Response.SetValue(Response);
return true;

View File

@@ -7,12 +7,15 @@ class FSocket;
class IMCPHandler;
/**
* FMCPServer — plain C++ class (not a UCLASS) that implements the
* Model Context Protocol (MCP) over a TCP socket using JSON-RPC.
* FMCPServer — plain C++ class (not a UCLASS) that listens on a TCP
* socket and dispatches JSON commands to blueprint editing handlers.
*
* Clients connect via TCP and exchange newline-delimited JSON-RPC messages
* (the MCP "stdio" protocol). Each connected client gets its own thread
* for blocking I/O; tool calls are dispatched on the game thread.
* Clients connect via TCP and exchange newline-delimited JSON messages.
* Request format: {"command": "tool_name", "param1": "value1", ...}
* Response format: raw JSON result from the handler.
*
* Each connected client gets its own thread for blocking I/O;
* tool calls are dispatched on the game thread.
*
* Both the standalone commandlet (UBlueprintMCPCommandlet) and the in-editor
* subsystem (UBlueprintMCPEditorSubsystem) delegate to an instance of this
@@ -55,25 +58,8 @@ private:
// Dispatch a tool call to the appropriate handler
void DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result);
// ----- MCP protocol -----
// Handle a complete JSON-RPC line and return the response line (or empty for notifications)
FString HandleJsonRpc(const FString& Line);
// MCP method handlers
FString HandleInitialize(int32 Id, const FJsonObject* Params);
FString HandleToolsList(int32 Id);
FString HandleToolsCall(int32 Id, const FJsonObject* Params);
// Build the tools/list response (cached after first call)
TSharedPtr<FJsonObject> CachedToolsList;
static TSharedRef<FJsonObject> FPropertyToPropSchema(FProperty* Prop);
static TSharedRef<FJsonObject> HandlerClassToToolSchema(UClass* HandlerClass);
void BuildCachedToolsList();
// JSON-RPC helpers
static FString MakeJsonRpcResult(int32 Id, TSharedPtr<FJsonObject> Result);
static FString MakeJsonRpcToolResult(int32 Id, TSharedPtr<FJsonObject> ToolResult, bool bIsError);
static FString MakeJsonRpcError(int32 Id, int32 Code, const FString& Message);
// Handle a complete JSON line and return the response JSON
FString HandleRequest(const FString& Line);
// ----- TCP server -----
FSocket* ListenSocket = nullptr;