From 4befd070dbbc54494afe85cb575b09a4e850324f Mon Sep 17 00:00:00 2001 From: jyelon Date: Sat, 7 Mar 2026 19:32:19 -0500 Subject: [PATCH] Protocol for MCP handler revised. --- .../Private/MCPHandlers_Discovery.h | 79 +++++ .../Source/BlueprintMCP/Private/MCPServer.cpp | 281 +----------------- .../Source/BlueprintMCP/Public/MCPServer.h | 34 +-- tools/mcp-bridge.py | 148 +++++---- tools/mcp-bridge.sh | 2 - 5 files changed, 176 insertions(+), 368 deletions(-) delete mode 100644 tools/mcp-bridge.sh diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h index 5039551f..461cc661 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h @@ -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> CommandsArray; + for (TObjectIterator It; It; ++It) + { + UClass* Class = *It; + if (Class->HasAnyClassFlags(CLASS_Abstract)) continue; + const IMCPHandler* Handler = Cast(Class->GetDefaultObject()); + if (!Handler) continue; + const FString& ToolName = Class->GetMetaData(TEXT("ToolName")); + if (ToolName.IsEmpty()) continue; + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("command"), ToolName); + Entry->SetStringField(TEXT("description"), Handler->GetDescription()); + + // Document parameters from UPROPERTY fields + TArray> ParamsArray; + for (TFieldIterator PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt) + { + FProperty* Prop = *PropIt; + TSharedRef ParamObj = MakeShared(); + ParamObj->SetStringField(TEXT("name"), MCPUtils::PropertyNameToJsonKey(Prop->GetName())); + ParamObj->SetBoolField(TEXT("required"), !Prop->HasMetaData(TEXT("Optional"))); + + // Type + if (CastField(Prop)) + ParamObj->SetStringField(TEXT("type"), TEXT("string")); + else if (CastField(Prop)) + ParamObj->SetStringField(TEXT("type"), TEXT("integer")); + else if (CastField(Prop) || CastField(Prop)) + ParamObj->SetStringField(TEXT("type"), TEXT("number")); + else if (CastField(Prop)) + ParamObj->SetStringField(TEXT("type"), TEXT("boolean")); + else if (FStructProperty* SP = CastField(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(ParamObj)); + } + if (ParamsArray.Num() > 0) + { + Entry->SetArrayField(TEXT("parameters"), ParamsArray); + } + + CommandsArray.Add(MakeShared(Entry)); + } + Result->SetNumberField(TEXT("count"), CommandsArray.Num()); + Result->SetArrayField(TEXT("commands"), CommandsArray); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp index a866b431..e57f40f5 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp @@ -248,229 +248,6 @@ int32 TryAddMaterialExpressionSEH( #endif // PLATFORM_WINDOWS -// ============================================================ -// JSON-RPC helpers -// ============================================================ - -FString FMCPServer::MakeJsonRpcResult(int32 Id, TSharedPtr Result) -{ - TSharedRef Response = MakeShared(); - Response->SetStringField(TEXT("jsonrpc"), TEXT("2.0")); - Response->SetNumberField(TEXT("id"), Id); - Response->SetObjectField(TEXT("result"), Result.IsValid() ? Result.ToSharedRef() : MakeShared()); - return MCPUtils::JsonToString(Response); -} - -FString FMCPServer::MakeJsonRpcToolResult(int32 Id, TSharedPtr ToolResult, bool bIsError) -{ - TSharedRef ContentItem = MakeShared(); - ContentItem->SetStringField(TEXT("type"), TEXT("text")); - ContentItem->SetStringField(TEXT("text"), ToolResult.IsValid() ? MCPUtils::JsonToString(ToolResult.ToSharedRef()) : TEXT("{}")); - - TArray> ContentArray; - ContentArray.Add(MakeShared(ContentItem)); - - TSharedRef Result = MakeShared(); - 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 ErrObj = MakeShared(); - ErrObj->SetNumberField(TEXT("code"), Code); - ErrObj->SetStringField(TEXT("message"), Message); - - TSharedRef Response = MakeShared(); - 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 FMCPServer::FPropertyToPropSchema(FProperty* Prop) -{ - TSharedRef Schema = MakeShared(); - - if (CastField(Prop)) - { - Schema->SetStringField(TEXT("type"), TEXT("string")); - } - else if (CastField(Prop)) - { - Schema->SetStringField(TEXT("type"), TEXT("integer")); - } - else if (CastField(Prop) || CastField(Prop)) - { - Schema->SetStringField(TEXT("type"), TEXT("number")); - } - else if (CastField(Prop)) - { - Schema->SetStringField(TEXT("type"), TEXT("boolean")); - } - else if (FStructProperty* StructProp = CastField(Prop)) - { - if (StructProp->Struct == FMCPJsonArray::StaticStruct()) - { - Schema->SetStringField(TEXT("type"), TEXT("array")); - } - else - { - Schema->SetStringField(TEXT("type"), TEXT("object")); - } - } - else if (FEnumProperty* EnumProp = CastField(Prop)) - { - Schema->SetStringField(TEXT("type"), TEXT("string")); - UEnum* Enum = EnumProp->GetEnum(); - TArray> EnumValues; - for (int32 i = 0; i < Enum->NumEnums() - 1; ++i) - { - EnumValues.Add(MakeShared(Enum->GetNameStringByIndex(i))); - } - Schema->SetArrayField(TEXT("enum"), EnumValues); - } - else if (FByteProperty* ByteProp = CastField(Prop)) - { - if (ByteProp->Enum) - { - Schema->SetStringField(TEXT("type"), TEXT("string")); - TArray> EnumValues; - for (int32 i = 0; i < ByteProp->Enum->NumEnums() - 1; ++i) - { - EnumValues.Add(MakeShared(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 FMCPServer::HandlerClassToToolSchema(UClass* HandlerClass) -{ - TSharedRef Tool = MakeShared(); - Tool->SetStringField(TEXT("name"), HandlerClass->GetMetaData(TEXT("ToolName"))); - - // Get description from the handler - UObject* TempObj = NewObject(GetTransientPackage(), HandlerClass); - IMCPHandler* Handler = Cast(TempObj); - if (Handler) - { - Tool->SetStringField(TEXT("description"), Handler->GetDescription()); - } - TempObj->MarkAsGarbage(); - - // Build input schema from UPROPERTY fields - TSharedRef InputSchema = MakeShared(); - InputSchema->SetStringField(TEXT("type"), TEXT("object")); - - TSharedRef Properties = MakeShared(); - TArray> RequiredArray; - - for (TFieldIterator 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(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> ToolsArray; - - // New-style handlers: have UPROPERTY metadata for parameter schemas - for (const auto& KV : MCPHandlerRegistry) - { - ToolsArray.Add(MakeShared(HandlerClassToToolSchema(KV.Value))); - } - - CachedToolsList = MakeShared(); - CachedToolsList->SetArrayField(TEXT("tools"), ToolsArray); -} - -// ============================================================ -// Handlers for MCP Methods -// ============================================================ - -FString FMCPServer::HandleInitialize(int32 Id, const FJsonObject* Params) -{ - TSharedRef Result = MakeShared(); - Result->SetStringField(TEXT("protocolVersion"), TEXT("2024-11-05")); - - TSharedRef Capabilities = MakeShared(); - TSharedRef ToolsCap = MakeShared(); - Capabilities->SetObjectField(TEXT("tools"), ToolsCap); - Result->SetObjectField(TEXT("capabilities"), Capabilities); - - TSharedRef ServerInfo = MakeShared(); - 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* ArgsPtr = nullptr; - Params->TryGetObjectField(TEXT("arguments"), ArgsPtr); - TSharedPtr Args = ArgsPtr ? *ArgsPtr : MakeShared(); - - TSharedRef ToolResult = MakeShared(); - 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 Request = MCPUtils::ParseBodyJson(Line); if (!Request.IsValid()) { - return MakeJsonRpcError(0, -32700, TEXT("Parse error")); + TSharedRef ErrResult = MakeShared(); + 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 ErrResult = MakeShared(); + 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* 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 Result = MakeShared(); + 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; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h index bf31d7d2..a40736a2 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h @@ -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 CachedToolsList; - static TSharedRef FPropertyToPropSchema(FProperty* Prop); - static TSharedRef HandlerClassToToolSchema(UClass* HandlerClass); - void BuildCachedToolsList(); - - // JSON-RPC helpers - static FString MakeJsonRpcResult(int32 Id, TSharedPtr Result); - static FString MakeJsonRpcToolResult(int32 Id, TSharedPtr 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; diff --git a/tools/mcp-bridge.py b/tools/mcp-bridge.py index b8d28d18..4fb93d2f 100644 --- a/tools/mcp-bridge.py +++ b/tools/mcp-bridge.py @@ -2,21 +2,42 @@ """ MCP stdio-to-TCP bridge for BlueprintMCP. -Reads JSON-RPC messages from stdin, forwards them to the BlueprintMCP TCP server, -and writes responses to stdout. If the editor isn't running, returns valid MCP -error responses instead of crashing. +Exposes a single MCP tool "unreal" that forwards JSON commands to the +BlueprintMCP TCP server in the Unreal Editor. The tool list is static, +so it works regardless of whether the editor is running at startup. """ import sys import json import socket -import time HOST = "localhost" PORT = 9847 CONNECT_TIMEOUT = 2 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 @@ -47,7 +68,7 @@ def disconnect(): 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" sock.sendall(data.encode()) @@ -63,86 +84,61 @@ def send_and_receive(message): continue -def editor_down_response(msg): - """Return a valid MCP error response indicating the editor is down.""" - 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 +def forward_to_editor(arguments): + """Forward arguments to the editor, return the result dict.""" if not connect(): - return editor_down_response(msg) - + return {"error": "Unreal Editor is not running. Start the editor and try again."} try: - return send_and_receive(msg) + return send_and_receive(arguments) except Exception: disconnect() # Retry once in case the connection was stale if connect(): try: - return send_and_receive(msg) + return send_and_receive(arguments) except Exception: 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(): diff --git a/tools/mcp-bridge.sh b/tools/mcp-bridge.sh deleted file mode 100644 index 80cdccc6..00000000 --- a/tools/mcp-bridge.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -exec /usr/bin/nc localhost 9847