Protocol for MCP handler revised.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user