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); 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 #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;

View File

@@ -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;

View File

@@ -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():

View File

@@ -1,2 +0,0 @@
#!/bin/bash
exec /usr/bin/nc localhost 9847