Protocol for MCP handler revised.
This commit is contained in:
@@ -593,3 +593,82 @@ public:
|
|||||||
Result->SetArrayField(TEXT("properties"), PropList);
|
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
|
#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)
|
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);
|
TSharedPtr<FJsonObject> Request = MCPUtils::ParseBodyJson(Line);
|
||||||
if (!Request.IsValid())
|
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;
|
FString Command;
|
||||||
if (!Request->TryGetStringField(TEXT("method"), Method))
|
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
|
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||||
int32 Id = 0;
|
DispatchToolCall(Command, Request.Get(), &*Result);
|
||||||
bool bIsNotification = !Request->HasField(TEXT("id"));
|
return MCPUtils::JsonToString(Result);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -785,7 +534,7 @@ bool FMCPServer::ProcessOneRequest()
|
|||||||
if (!Msg.IsValid()) return false;
|
if (!Msg.IsValid()) return false;
|
||||||
|
|
||||||
// Process on game thread
|
// Process on game thread
|
||||||
FString Response = HandleJsonRpc(Msg->Line);
|
FString Response = HandleRequest(Msg->Line);
|
||||||
Msg->Response.SetValue(Response);
|
Msg->Response.SetValue(Response);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ class FSocket;
|
|||||||
class IMCPHandler;
|
class IMCPHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FMCPServer — plain C++ class (not a UCLASS) that implements the
|
* FMCPServer — plain C++ class (not a UCLASS) that listens on a TCP
|
||||||
* Model Context Protocol (MCP) over a TCP socket using JSON-RPC.
|
* socket and dispatches JSON commands to blueprint editing handlers.
|
||||||
*
|
*
|
||||||
* Clients connect via TCP and exchange newline-delimited JSON-RPC messages
|
* Clients connect via TCP and exchange newline-delimited JSON messages.
|
||||||
* (the MCP "stdio" protocol). Each connected client gets its own thread
|
* Request format: {"command": "tool_name", "param1": "value1", ...}
|
||||||
* for blocking I/O; tool calls are dispatched on the game thread.
|
* 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
|
* Both the standalone commandlet (UBlueprintMCPCommandlet) and the in-editor
|
||||||
* subsystem (UBlueprintMCPEditorSubsystem) delegate to an instance of this
|
* subsystem (UBlueprintMCPEditorSubsystem) delegate to an instance of this
|
||||||
@@ -55,25 +58,8 @@ private:
|
|||||||
// Dispatch a tool call to the appropriate handler
|
// Dispatch a tool call to the appropriate handler
|
||||||
void DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result);
|
void DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result);
|
||||||
|
|
||||||
// ----- MCP protocol -----
|
// Handle a complete JSON line and return the response JSON
|
||||||
// Handle a complete JSON-RPC line and return the response line (or empty for notifications)
|
FString HandleRequest(const FString& Line);
|
||||||
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);
|
|
||||||
|
|
||||||
// ----- TCP server -----
|
// ----- TCP server -----
|
||||||
FSocket* ListenSocket = nullptr;
|
FSocket* ListenSocket = nullptr;
|
||||||
|
|||||||
@@ -2,21 +2,42 @@
|
|||||||
"""
|
"""
|
||||||
MCP stdio-to-TCP bridge for BlueprintMCP.
|
MCP stdio-to-TCP bridge for BlueprintMCP.
|
||||||
|
|
||||||
Reads JSON-RPC messages from stdin, forwards them to the BlueprintMCP TCP server,
|
Exposes a single MCP tool "unreal" that forwards JSON commands to the
|
||||||
and writes responses to stdout. If the editor isn't running, returns valid MCP
|
BlueprintMCP TCP server in the Unreal Editor. The tool list is static,
|
||||||
error responses instead of crashing.
|
so it works regardless of whether the editor is running at startup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
import time
|
|
||||||
|
|
||||||
HOST = "localhost"
|
HOST = "localhost"
|
||||||
PORT = 9847
|
PORT = 9847
|
||||||
CONNECT_TIMEOUT = 2
|
CONNECT_TIMEOUT = 2
|
||||||
READ_TIMEOUT = 120
|
READ_TIMEOUT = 120
|
||||||
|
|
||||||
|
TOOL_DESCRIPTION = (
|
||||||
|
"Send a command to the Unreal Editor's BlueprintMCP plugin. "
|
||||||
|
"The 'command' field specifies which operation to perform; "
|
||||||
|
"additional fields are command-specific parameters. "
|
||||||
|
'Use {"command": "show_commands"} to list available commands. '
|
||||||
|
"If the editor is not running, the call will return an error; "
|
||||||
|
"just ask the user to start the editor and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
TOOL_SCHEMA = {
|
||||||
|
"name": "unreal",
|
||||||
|
"description": TOOL_DESCRIPTION,
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string", "description": "The command to execute"},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
"additionalProperties": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
sock = None
|
sock = None
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +68,7 @@ def disconnect():
|
|||||||
|
|
||||||
|
|
||||||
def send_and_receive(message):
|
def send_and_receive(message):
|
||||||
"""Send a JSON-RPC message to the editor and return the response."""
|
"""Send a JSON message to the editor and return the response."""
|
||||||
data = json.dumps(message) + "\n"
|
data = json.dumps(message) + "\n"
|
||||||
sock.sendall(data.encode())
|
sock.sendall(data.encode())
|
||||||
|
|
||||||
@@ -63,86 +84,61 @@ def send_and_receive(message):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def editor_down_response(msg):
|
def forward_to_editor(arguments):
|
||||||
"""Return a valid MCP error response indicating the editor is down."""
|
"""Forward arguments to the editor, return the result dict."""
|
||||||
msg_id = msg.get("id", 0)
|
|
||||||
method = msg.get("method", "")
|
|
||||||
|
|
||||||
if method == "initialize":
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": msg_id,
|
|
||||||
"result": {
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {"tools": {}},
|
|
||||||
"serverInfo": {"name": "blueprint-mcp", "version": "1.0.0"},
|
|
||||||
"_notice": "Unreal Editor is not running. No tools are available. Start the editor and restart Claude Code.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if method == "notifications/initialized":
|
|
||||||
return None # No response needed for notifications
|
|
||||||
|
|
||||||
if method == "tools/list":
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": msg_id,
|
|
||||||
"result": {
|
|
||||||
"tools": [{
|
|
||||||
"name": "editor_not_running",
|
|
||||||
"description": "Unreal Editor is not running. Start the editor and restart Claude Code to get BlueprintMCP tools.",
|
|
||||||
"inputSchema": {"type": "object", "properties": {}},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if method == "tools/call":
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": msg_id,
|
|
||||||
"result": {
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": json.dumps({"error": "Unreal Editor is down."})}
|
|
||||||
],
|
|
||||||
"isError": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": msg_id,
|
|
||||||
"error": {"code": -32000, "message": "Unreal Editor is down."},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def handle_message(msg):
|
|
||||||
"""Handle one JSON-RPC message. Try the editor first, fall back to offline responses."""
|
|
||||||
method = msg.get("method", "")
|
|
||||||
|
|
||||||
# Notifications don't get responses
|
|
||||||
if "id" not in msg:
|
|
||||||
if connect():
|
|
||||||
try:
|
|
||||||
sock.sendall((json.dumps(msg) + "\n").encode())
|
|
||||||
except Exception:
|
|
||||||
disconnect()
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try connecting (or reconnecting) to the editor
|
|
||||||
if not connect():
|
if not connect():
|
||||||
return editor_down_response(msg)
|
return {"error": "Unreal Editor is not running. Start the editor and try again."}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return send_and_receive(msg)
|
return send_and_receive(arguments)
|
||||||
except Exception:
|
except Exception:
|
||||||
disconnect()
|
disconnect()
|
||||||
# Retry once in case the connection was stale
|
# Retry once in case the connection was stale
|
||||||
if connect():
|
if connect():
|
||||||
try:
|
try:
|
||||||
return send_and_receive(msg)
|
return send_and_receive(arguments)
|
||||||
except Exception:
|
except Exception:
|
||||||
disconnect()
|
disconnect()
|
||||||
return editor_down_response(msg)
|
return {"error": "Lost connection to Unreal Editor."}
|
||||||
|
|
||||||
|
|
||||||
|
def make_jsonrpc(msg_id, result):
|
||||||
|
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_message(msg):
|
||||||
|
"""Handle one JSON-RPC message from Claude Code."""
|
||||||
|
msg_id = msg.get("id")
|
||||||
|
method = msg.get("method", "")
|
||||||
|
|
||||||
|
# Notifications don't get responses
|
||||||
|
if msg_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
return make_jsonrpc(msg_id, {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "blueprint-mcp", "version": "1.0.0"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if method == "tools/list":
|
||||||
|
return make_jsonrpc(msg_id, {"tools": [TOOL_SCHEMA]})
|
||||||
|
|
||||||
|
if method == "tools/call":
|
||||||
|
params = msg.get("params", {})
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
result = forward_to_editor(arguments)
|
||||||
|
is_error = "error" in result
|
||||||
|
return make_jsonrpc(msg_id, {
|
||||||
|
"content": [{"type": "text", "text": json.dumps(result)}],
|
||||||
|
**({"isError": True} if is_error else {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": msg_id,
|
||||||
|
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
exec /usr/bin/nc localhost 9847
|
|
||||||
Reference in New Issue
Block a user