Refactoring ue-wingman to be a command-line only tool

This commit is contained in:
2026-05-13 21:36:40 -04:00
parent ff9c045c8e
commit e0d45cc1db
39 changed files with 533 additions and 866 deletions

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"ue-wingman": {
"command": "python3",
"args": ["Plugins/UEWingman/ue-wingman-mcp.py"]
}
}
}

View File

@@ -25,7 +25,7 @@
- `Docs/` — Documentation. - `Docs/` — Documentation.
- `Config/` — Unreal config files - `Config/` — Unreal config files
- `EnginePatches/` — Custom engine modifications - `EnginePatches/` — Custom engine modifications
- `Plugins/UEWingman/' - An MCP that gives you control over the unreal editor. - `Plugins/UEWingman/` - A plugin that gives you control over the unreal editor. Drive it from bash via `python3 Plugins/UEWingman/ue-wingman.py <Command> key=value ...` (values starting with `[` or `{` are parsed as JSON).
- `../integration.UE/` - the unreal engine source tree - `../integration.UE/` - the unreal engine source tree
## Coding Conventions ## Coding Conventions

BIN
Content/testing/WB_radtest.uasset LFS Normal file

Binary file not shown.

View File

@@ -25,7 +25,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Asset to delete")) UPROPERTY(EditAnywhere, meta=(Description="Asset to delete"))
FString Asset; FString Asset;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, skip reference check and force delete")) UPROPERTY(EditAnywhere, meta=(Description="If true, skip reference check and force delete"))
bool Force = false; bool Force = false;
virtual void Register() override virtual void Register() override

View File

@@ -21,13 +21,13 @@ class UWing_Asset_Search : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring to match against asset package paths")) UPROPERTY(EditAnywhere, meta=(Description="Substring to match against asset package paths"))
FString Query; FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Asset class name to filter by, e.g. Blueprint, Material, StaticMesh")) UPROPERTY(EditAnywhere, meta=(Description="Asset class name to filter by, e.g. Blueprint, Material, StaticMesh"))
FString Type; FString Type;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results (default 50)")) UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results (default 50)"))
int32 Limit = 50; int32 Limit = 50;
virtual void Register() override virtual void Register() override

View File

@@ -32,16 +32,15 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Type of graph: function or macro")) UPROPERTY(EditAnywhere, meta=(Description="Type of graph: function or macro"))
FString GraphType; FString GraphType;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variables, one per line")) UPROPERTY(EditAnywhere, meta=(Description="Variables"))
FString InputVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variables, one per line"))
FString OutputVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Create a new function or macro graph in a Blueprint.")); TEXT("Create a new function or macro graph in a Blueprint. "
"Variables must be expressed as 'kind type name (flags) = default'. "
"Kind can be input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -79,8 +78,7 @@ public:
// Parse and validate variables before making changes // Parse and validate variables before making changes
WingVariables Vars; WingVariables Vars;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
// Create the Graph // Create the Graph
UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, InternalID, UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, InternalID,

View File

@@ -27,7 +27,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Interface name to remove")) UPROPERTY(EditAnywhere, meta=(Description="Interface name to remove"))
FString Interface; FString Interface;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, keep the function graphs as regular functions")) UPROPERTY(EditAnywhere, meta=(Description="If true, keep the function graphs as regular functions"))
bool PreserveFunctions = false; bool PreserveFunctions = false;
virtual void Register() override virtual void Register() override

View File

@@ -37,5 +37,6 @@ public:
if (!P) return; if (!P) return;
WingOut::Stdout.Print(P->GetText()); WingOut::Stdout.Print(P->GetText());
WingOut::Stdout.Print(TEXT("\n"));
} }
}; };

View File

@@ -0,0 +1,27 @@
#pragma once
#include "CoreMinimal.h"
#include "WingBasics.h"
#include "WingServer.h"
#include "WingManual.h"
#include "Documentation_Command.generated.h"
UCLASS()
class UWing_Documentation_Command : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Substring filter for command names"))
FString Command;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Detailed documentation for one or more commands."));
}
virtual void Handle() override
{
WingManual::Commands(EWingHandlerKind::Normal, Command, true);
}
};

View File

@@ -12,19 +12,13 @@ class UWing_Documentation_Commands : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring filter for command names"))
FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, return full details including parameter types and descriptions"))
bool Verbose = false;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("List all the main commands with their descriptions.")); TEXT("A concise list of all ue-wingman commands."));
} }
virtual void Handle() override virtual void Handle() override
{ {
WingManual::Commands(EWingHandlerKind::Normal, Query, Verbose); WingManual::Commands(EWingHandlerKind::Normal, TEXT(""), false);
} }
}; };

View File

@@ -12,10 +12,10 @@ class UWing_Documentation_CreateAssets : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring filter for command names")) UPROPERTY(EditAnywhere, meta=(Description="Substring filter for command names"))
FString Query; FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, return full details including parameter types and descriptions")) UPROPERTY(EditAnywhere, meta=(Description="If true, return full details including parameter types and descriptions"))
bool Verbose = false; bool Verbose = false;
virtual void Register() override virtual void Register() override

View File

@@ -12,7 +12,7 @@ class UWing_Documentation_Manual : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="section of the manual")) UPROPERTY(EditAnywhere, meta=(Description="section of the manual"))
FString Section; FString Section;
virtual void Register() override virtual void Register() override
@@ -44,7 +44,7 @@ public:
} }
else else
{ {
WingOut::Stdout.Printf(TEXT("Unknown manual section '%s'\n")); WingOut::Stdout.Printf(TEXT("Unknown manual section '%s'\n"), *Section);
WingManual::PrintSectionNames(TEXT("Valid manual sections:"), Sections, WingOut::Stdout); WingManual::PrintSectionNames(TEXT("Valid manual sections:"), Sections, WingOut::Stdout);
} }
} }

View File

@@ -28,13 +28,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Name of the new event dispatcher")) UPROPERTY(EditAnywhere, meta=(Description="Name of the new event dispatcher"))
FString Dispatcher; FString Dispatcher;
UPROPERTY(EditAnywhere, meta=(Description="Input Variables, one per line, expressed as: type var = value")) UPROPERTY(EditAnywhere, meta=(Description="Variables"))
FString InputVariables; FWingRestOfArgv Variables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Add a new event dispatcher to a Blueprint.")); TEXT("Add a new event dispatcher to a Blueprint. "
"Variables must be expressed as 'kind type name (flags) = default'. "
"Kind can only be 'input'."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -49,7 +52,7 @@ public:
// Parse the arguments. // Parse the arguments.
WingVariables Vars; WingVariables Vars;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
// Add the delegate variable // Add the delegate variable
FEdGraphPinType DelegateType; FEdGraphPinType DelegateType;

View File

@@ -21,7 +21,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target node")) UPROPERTY(EditAnywhere, meta=(Description="Target node"))
FString Node; FString Node;
UPROPERTY(EditAnywhere, meta=(Optional, Description="True to show minor node properties")) UPROPERTY(EditAnywhere, meta=(Description="True to show minor node properties"))
bool Details = false; bool Details = false;
virtual void Register() override virtual void Register() override

View File

@@ -22,7 +22,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to graph")) UPROPERTY(EditAnywhere, meta=(Description="Path to graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Optional, Description="True to show minor node properties")) UPROPERTY(EditAnywhere, meta=(Description="True to show minor node properties"))
bool Details = false; bool Details = false;
virtual void Register() override virtual void Register() override

View File

@@ -20,7 +20,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Substring filter for type names")) UPROPERTY(EditAnywhere, meta=(Description="Substring filter for type names"))
FString Query; FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results")) UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results"))
int32 Limit = 100; int32 Limit = 100;
virtual void Register() override virtual void Register() override

View File

@@ -21,22 +21,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node")) UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object; FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variables, one per line")) UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FString BlueprintVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variables, one per line"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variables, one per line"))
FString OutputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Local variables, one per line"))
FString LocalVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Add new variables. Format: 'type name (flags) = default', one per line.")); TEXT("Add variables to a blueprint, function graph, "
"macro graph, event dispatcher graph, or custom event node. "
"Each variable must be expressed as: 'kind type name (flags) = default'. "
"Kind can be blueprint, input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -46,10 +40,7 @@ public:
WingVariables Vars; WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return; if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseString(BlueprintVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.LocalVariables.ParseString(LocalVariables, WingOut::Stdout)) return;
if (!Vars.Check(WingOut::Stdout)) return; if (!Vars.Check(WingOut::Stdout)) return;
if (!Vars.Create(WingOut::Stdout)) return; if (!Vars.Create(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n")); WingOut::Stdout.Printf(TEXT("Success.\n"));

View File

@@ -21,23 +21,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node")) UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object; FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variables, one per line")) UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FString BlueprintVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variables, one per line"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variables, one per line"))
FString OutputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Local variables, one per line"))
FString LocalVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Modify variables of a blueprint, function graph, " TEXT("Add variables to a blueprint, function graph, "
"macro graph, event dispatcher graph, or custom event node. ")); "macro graph, event dispatcher graph, or custom event node. "
"Each variable must be expressed as: 'kind type name (flags) = default'. "
"Kind can be blueprint, input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -47,10 +40,7 @@ public:
WingVariables Vars; WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return; if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseString(BlueprintVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.LocalVariables.ParseString(LocalVariables, WingOut::Stdout)) return;
if (!Vars.Check(WingOut::Stdout)) return; if (!Vars.Check(WingOut::Stdout)) return;
if (!Vars.Modify(WingOut::Stdout)) return; if (!Vars.Modify(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n")); WingOut::Stdout.Printf(TEXT("Success.\n"));

View File

@@ -21,22 +21,15 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node")) UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object; FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variable names to remove, comma-separated")) UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FString BlueprintVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variable names to remove, comma-separated"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variable names to remove, comma-separated"))
FString OutputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Local variable names to remove, comma-separated"))
FString LocalVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Remove variables from a blueprint, graph, or custom event node.")); TEXT("Remove variables from a blueprint, graph, or custom event node. "
"Each variable must be expressed as: 'kind name'. "
"Kind can be blueprint, input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -46,10 +39,7 @@ public:
WingVariables Vars; WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return; if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseNamesString(BlueprintVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, true, WingOut::Stdout)) return;
if (!Vars.InputVariables.ParseNamesString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseNamesString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.LocalVariables.ParseNamesString(LocalVariables, WingOut::Stdout)) return;
if (!Vars.Remove(WingOut::Stdout)) return; if (!Vars.Remove(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n")); WingOut::Stdout.Printf(TEXT("Success.\n"));
} }

View File

@@ -33,10 +33,10 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Name for the new widget")) UPROPERTY(EditAnywhere, meta=(Description="Name for the new widget"))
FString Name; FString Name;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Parent widget name. If omitted, sets as root.")) UPROPERTY(EditAnywhere, meta=(Description="Parent widget name. If omitted, sets as root."))
FString Parent; FString Parent;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Whether to expose the widget as a variable in the blueprint (default false)")) UPROPERTY(EditAnywhere, meta=(Description="Whether to expose the widget as a variable in the blueprint (default false)"))
bool IsVariable = false; bool IsVariable = false;
virtual void Register() override virtual void Register() override
@@ -117,6 +117,10 @@ public:
BP->WidgetTree->RootWidget = NewWidget; BP->WidgetTree->RootWidget = NewWidget;
} }
// Register a variable GUID for the new widget. UMG's compiler
// ensures every widget in the tree is present in this map.
BP->OnVariableAdded(NewWidget->GetFName());
WingOut::Stdout.Printf(TEXT("Created widget '%s' of type '%s'\n"), *Name, *Type); WingOut::Stdout.Printf(TEXT("Created widget '%s' of type '%s'\n"), *Name, *Type);
} }
}; };

View File

@@ -6,17 +6,23 @@
void WingManual::PrintHandlerPrototype(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerPrototype(const FWingHandlerConfig& Handler)
{ {
WingOut::Stdout.Print(TEXT("ue-wingman "));
WingOut::Stdout.Print(Handler.Name); WingOut::Stdout.Print(Handler.Name);
WingOut::Stdout.Print(TEXT("("));
bool bFirst = true;
for (TFieldIterator<FProperty> PropIt(Handler.HandlerClass.Get(), EFieldIterationFlags::None); PropIt; ++PropIt) for (TFieldIterator<FProperty> PropIt(Handler.HandlerClass.Get(), EFieldIterationFlags::None); PropIt; ++PropIt)
{ {
if (!bFirst) WingOut::Stdout.Print(TEXT(",")); FStructProperty* StructProp = CastField<FStructProperty>(*PropIt);
bFirst = false; const bool bIsRest =
if (PropIt->HasMetaData(TEXT("Optional"))) WingOut::Stdout.Print(TEXT("?")); StructProp && (StructProp->Struct == FWingRestOfArgv::StaticStruct());
WingOut::Stdout.Print(PropIt->GetName()); if (bIsRest)
{
WingOut::Stdout.Printf(TEXT(" [%s...]"), *PropIt->GetName());
}
else
{
WingOut::Stdout.Printf(TEXT(" %s"), *PropIt->GetName());
}
} }
WingOut::Stdout.Print(TEXT(")\n")); WingOut::Stdout.Print(TEXT("\n"));
} }
void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler)
@@ -26,197 +32,193 @@ void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler)
{ {
FProperty* Prop = *PropIt; FProperty* Prop = *PropIt;
FString Name = Prop->GetName(); FString Name = Prop->GetName();
FString Type = UWingTypes::TypeToText(Prop); FString Desc = Prop->GetMetaData(TEXT("Description"));
bool bOptional = Prop->HasMetaData(TEXT("Optional")); if (Desc.IsEmpty()) Desc = TEXT("No documentation");
const FString& Desc = Prop->GetMetaData(TEXT("Description"));
if (bOptional) WingOut::Stdout.Printf(TEXT(" %s - %s\n"), *Name, *Desc);
{
WingOut::Stdout.Printf(TEXT(" %s (optional %s)"), *Name, *Type);
}
else
{
WingOut::Stdout.Printf(TEXT(" %s (%s)"), *Name, *Type);
}
if (!Desc.IsEmpty()) WingOut::Stdout.Printf(TEXT(" — %s"), *Desc);
WingOut::Stdout.Print(TEXT("\n"));
} }
} }
void WingManual::PrintHandlerDescription(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerDescription(const FWingHandlerConfig& Handler)
{ {
if (Handler.Documentation.IsEmpty()) return; if (Handler.Documentation.IsEmpty()) return;
WingOut::Stdout.Print(WingUtils::WrapText(Handler.Documentation, 80, TEXT(" // "))); WingOut::Stdout.Printf(TEXT("\n%s\n\n"), *Handler.Documentation);
} }
void WingManual::PrintHandlerHelp(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerHelp(const FWingHandlerConfig& Handler)
{ {
WingOut::Stdout.Print(TEXT("\n")); WingOut::Stdout.Print(TEXT("\n"));
PrintHandlerPrototype(Handler); PrintHandlerPrototype(Handler);
PrintHandlerArguments(Handler); PrintHandlerArguments(Handler);
PrintHandlerDescription(Handler); PrintHandlerDescription(Handler);
WingOut::Stdout.Print(TEXT("\n")); WingOut::Stdout.Print(TEXT("\n"));
} }
void UWingManualSections::FetcherPaths() void UWingManualSections::FetcherPaths()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n FETCHER PATHS:" "\n FETCHER PATHS:"
"\n" "\n"
"\n Most commands require you to specify a 'fetcher path'." "\n Most commands require you to specify a 'fetcher path'."
"\n A fetcher path starts with an asset name, followed by" "\n A fetcher path starts with an asset name, followed by"
"\n steps that navigate into the asset. Some Examples:" "\n steps that navigate into the asset. Some Examples:"
"\n" "\n"
"\n /Game/Widgets/WB_Hotkeys,widget:Canvas.122" "\n /Game/Widgets/WB_Hotkeys,widget:Canvas.122"
"\n /Game/Testing/BP_Test,graph:Rescale.Actor,node:K2Node_CallFunction_0,pin:Scale" "\n /Game/Testing/BP_Test,graph:Rescale.Actor,node:K2Node_CallFunction_0,pin:Scale"
"\n /Game/Chars/BP_Manny,component:Camera.Boom" "\n /Game/Chars/BP_Manny,component:Camera.Boom"
"\n" "\n"
"\n The navigation steps supported are:" "\n The navigation steps supported are:"
"\n" "\n"
"\n graph — move from a blueprint or material to a graph." "\n graph — move from a blueprint or material to a graph."
"\n node — move from a graph to a graph node" "\n node — move from a graph to a graph node"
"\n pin — move from a graph node to a pin" "\n pin — move from a graph node to a pin"
"\n component — move from a blueprint to a component" "\n component — move from a blueprint to a component"
"\n levelblueprint — move from a world to a blueprint" "\n levelblueprint — move from a world to a blueprint"
"\n widget — move from a widget blueprint to a widget" "\n widget — move from a widget blueprint to a widget"
"\n structprop — move into a struct property of an object" "\n structprop — move into a struct property of an object"
"\n" "\n"
"\n Notice that paths use escaped fnames. See the section" "\n Notice that paths use escaped fnames. See the section"
"\n on escape sequences in fnames below sfor more information." "\n on escape sequences in fnames below sfor more information."
"\n" "\n"
"\n Steps do not always require a parameter. For example, materials" "\n Steps do not always require a parameter. For example, materials"
"\n only have one graph, so you can just say:" "\n only have one graph, so you can just say:"
"\n" "\n"
"\n /Game/Materials/MyMaterial,graph" "\n /Game/Materials/MyMaterial,graph"
"\n" "\n"
)); ));
} }
void UWingManualSections::ExpressingTypes() void UWingManualSections::ExpressingTypes()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n EXPRESSING TYPES:" "\n EXPRESSING TYPES:"
"\n" "\n"
"\n To change the type of a variable, or to express function parameters," "\n To change the type of a variable, or to express function parameters,"
"\n you will use our syntax for types. Here are some valid examples:" "\n you will use our syntax for types. Here are some valid examples:"
"\n" "\n"
"\n Bool, String, Vector, Rotator, HitResult, Actor, Character," "\n Bool, String, Vector, Rotator, HitResult, Actor, Character,"
"\n PlayerController, EBlendMode, EMovementMode, BP_Manny, BP_Quinn," "\n PlayerController, EBlendMode, EMovementMode, BP_Manny, BP_Quinn,"
"\n Array<Int>, Set<String>, Map<Int,Actor>" "\n Array<Int>, Set<String>, Map<Int,Actor>"
"\n Soft<ABP_Manny>, Class<Pawn>, SoftClass<Pawn>" "\n Soft<ABP_Manny>, Class<Pawn>, SoftClass<Pawn>"
"\n" "\n"
"\n Notice that it's 'Actor', not 'AActor'. Type names are not" "\n Notice that it's 'Actor', not 'AActor'. Type names are not"
"\n case-sensitive. When a blueprint like /Game/Testing/BP_Foo" "\n case-sensitive. When a blueprint like /Game/Testing/BP_Foo"
"\n is used as a type, the typename is just BP_Foo. You can search" "\n is used as a type, the typename is just BP_Foo. You can search"
"\n for valid types using the TypeName_Search command." "\n for valid types using the TypeName_Search command."
"\n" "\n"
)); ));
} }
void UWingManualSections::VariableDeclarations() void UWingManualSections::VariableDeclarations()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n VARIABLE DECLARATIONS:" "\n VARIABLE DECLARATIONS:"
"\n" "\n"
"\n We have our own syntax for variable declarations: a type," "\n We have our own syntax for variable declarations:"
"\n a name, optional flags, and an optional default value," "\n"
"\n always on one line:" "\n kind type name (optional flags) = optional default value"
"\n" "\n"
"\n Array<Actor> Actors" "\n Kind can be:"
"\n Float F (InstanceEditable)" "\n"
"\n String S = This is the default value" "\n blueprint - eg, instance variables"
"\n" "\n input - a function argument or macro input"
"\n The commands Variables_Add, Variables_Modify," "\n output - a function return value or macro output"
"\n and Variables_Remove can be used to edit " "\n local - local variables"
"\n blueprint variables, graph local variables, graph input" "\n"
"\n variables, graph output variables, and custom" "\n Here are some examples:"
"\n event node input variables. Event dispatchers are" "\n"
"\n also graphs, so they too can be edited." "\n input Array<Actor> Actors"
"\n" "\n output Float F (InstanceEditable)"
)); "\n blueprint String S = This is the default value"
"\n"
"\n The commands Variables_Add, Variables_Modify,"
"\n and Variables_Remove can be used to edit "
"\n blueprint variables, graph local variables, graph input"
"\n variables, graph output variables, and custom"
"\n event node input variables. Event dispatchers are"
"\n also graphs, so they too can be edited."
"\n"
));
} }
void UWingManualSections::EscapeSequencesInFNames() void UWingManualSections::EscapeSequencesInFNames()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n ESCAPE SEQUENCES IN FNAMES:" "\n ESCAPE SEQUENCES IN FNAMES:"
"\n" "\n"
"\n When we output FNames, we use HTML escape sequences for the" "\n When we output FNames, we use HTML escape sequences for the"
"\n following marks: \\\"'(),.:;<=>&, and for certain other characters." "\n following marks: \\\"'(),.:;<=>&, and for certain other characters."
"\n We also translate spaces to periods." "\n We also translate spaces to periods."
"\n" "\n"
"\n When sending FNames to UE Wingman, you *must* escape the marks" "\n When sending FNames to UE Wingman, you *must* escape the marks"
"\n listed above, but you *may* escape any character. To send an FName" "\n listed above, but you *may* escape any character. To send an FName"
"\n with a space in it, either use &#32; or a period." "\n with a space in it, either use &#32; or a period."
"\n" "\n"
)); ));
} }
void UWingManualSections::MaterialEditing() void UWingManualSections::MaterialEditing()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n MATERIAL EDITING:" "\n MATERIAL EDITING:"
"\n" "\n"
"\n We do not expose material expressions directly. Instead, you" "\n We do not expose material expressions directly. Instead, you"
"\n will be editing the material graph. However, if you Graph_Dump" "\n will be editing the material graph. However, if you Graph_Dump"
"\n a material graph, you will see that the nodes contain" "\n a material graph, you will see that the nodes contain"
"\n properties which actually come from the material expressions." "\n properties which actually come from the material expressions."
"\n You can edit these using Details_Set on the node." "\n You can edit these using Details_Set on the node."
"\n" "\n"
"\n Don't overlook custom HLSL nodes. These can accomplish in\n" "\n Don't overlook custom HLSL nodes. These can accomplish in\n"
"\n a single node what would otherwise take many.\n" "\n a single node what would otherwise take many.\n"
"\n" "\n"
)); ));
} }
void UWingManualSections::NodeContextMenus() void UWingManualSections::NodeContextMenus()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n NODE CONTEXT MENUS:" "\n NODE CONTEXT MENUS:"
"\n" "\n"
"\n GraphNode_ShowMenu and GraphNode_ChooseMenu give access" "\n GraphNode_ShowMenu and GraphNode_ChooseMenu give access"
"\n to the node context menu. This menu includes both node" "\n to the node context menu. This menu includes both node"
"\n operations and pin operations (e.g. Split Struct Pin," "\n operations and pin operations (e.g. Split Struct Pin,"
"\n Add Pin)." "\n Add Pin)."
"\n" "\n"
)); ));
} }
void UWingManualSections::VariableGettersAndSetters() void UWingManualSections::VariableGettersAndSetters()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n VARIABLE GETTERS AND SETTERS:" "\n VARIABLE GETTERS AND SETTERS:"
"\n" "\n"
"\n Access to local vars, function parameters, and " "\n Access to local vars, function parameters, and "
"\n blueprint vars is through getter and setter nodes. " "\n blueprint vars is through getter and setter nodes. "
"\n These can be found in GraphNode_SearchTypes by " "\n These can be found in GraphNode_SearchTypes by "
"\n searching for 'Variable'. Some examples:" "\n searching for 'Variable'. Some examples:"
"\n" "\n"
"\n SKEL_WB_Menu_C|Variables|Default|GetPlaceTangible" "\n SKEL_WB_Menu_C|Variables|Default|GetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|Default|SetPlaceTangible" "\n SKEL_WB_Menu_C|Variables|Default|SetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|WB_Menu|GetMenuPanel" "\n SKEL_WB_Menu_C|Variables|WB_Menu|GetMenuPanel"
"\n" "\n"
)); ));
} }
void UWingManualSections::ImportantCommands() void UWingManualSections::ImportantCommands()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n IMPORTANT COMMANDS:" "\n IMPORTANT COMMANDS:"
"\n" "\n"
"\n Documentation_Manual: print manual sections" "\n Documentation_Manual: print manual sections"
"\n Documentation_Commands: a list of all the main commands" "\n Documentation_Commands: A concise list of all ue-wingman commands"
"\n Documentation_CreateAssets: Additional commands that create new assets" "\n Documentation_Command: Detailed documentation for a single ue-wingman command"
"\n Blueprint_Dump: a summary of any blueprint" "\n Documentation_CreateAssets: Additional commands that create new assets"
"\n Graph_Dump: a fairly detailed listing of any Graph" "\n Blueprint_Dump: a summary of any blueprint"
"\n Details_Dump: Dump the details panel for a given object" "\n Graph_Dump: a fairly detailed listing of any Graph"
"\n Details_Set: Manipulate the details panel for a given object" "\n Details_Dump: Dump the details panel for a given object"
"\n Sequence: Batch commands together for faster execution" "\n Details_Set: Manipulate the details panel for a given object"
"\n" "\n"
"\n You can use Documentation_Commands(Query=Command,Verbose=true)" ));
"\n to get detailed help for a specific command."
"\n"
));
} }
TSet<FName> WingManual::GetSections() TSet<FName> WingManual::GetSections()
@@ -232,7 +234,7 @@ TSet<FName> WingManual::GetSections()
void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Sections, WingOut Output) void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Sections, WingOut Output)
{ {
if (Sections.IsEmpty()) return; if (Sections.IsEmpty()) return;
if (Prefix) Output.Print(Prefix); if (Prefix) Output.Print(Prefix);
bool bFirst = true; bool bFirst = true;
for (const FName& Section : Sections) for (const FName& Section : Sections)
{ {
@@ -240,7 +242,7 @@ void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Secti
bFirst = false; bFirst = false;
Output.Printf(TEXT("%s"), *Section.ToString()); Output.Printf(TEXT("%s"), *Section.ToString());
} }
if (Prefix) Output.Print(TEXT("\n")); if (Prefix) Output.Print(TEXT("\n"));
} }
bool WingManual::PrintSection(FName Section) bool WingManual::PrintSection(FName Section)
@@ -256,10 +258,12 @@ void WingManual::Commands(EWingHandlerKind Kind, const FString& Query, bool Verb
FString QueryLower = Query.ToLower(); FString QueryLower = Query.ToLower();
FString PrevGroup; FString PrevGroup;
bool any = false;
for (const FWingHandlerConfig& H : UWingServer::AllHandlers()) for (const FWingHandlerConfig& H : UWingServer::AllHandlers())
{ {
if (H.Kind != Kind) continue; if (H.Kind != Kind) continue;
if (!H.Name.ToLower().Contains(QueryLower)) continue; if (!H.Name.ToLower().Contains(QueryLower)) continue;
any = true;
// Blank line between groups // Blank line between groups
if (!Verbose) if (!Verbose)
@@ -278,4 +282,9 @@ void WingManual::Commands(EWingHandlerKind Kind, const FString& Query, bool Verb
else else
PrintHandlerPrototype(H); PrintHandlerPrototype(H);
} }
if (!any)
{
WingOut::Stdout.Print(TEXT("No matching commands. To see a full list, type:\n"));
WingOut::Stdout.Print(TEXT(" ue-wingman Documentation_Commands.\n"));
}
} }

View File

@@ -194,57 +194,6 @@ bool FWingProperty::SetText(FString Value, WingOut Errors) const
return true; return true;
} }
bool FWingProperty::SetJson(const FJsonValue &JsonValue, WingOut Errors) const
{
if (!CheckEditable(Errors)) return false;
if (JsonValue.Type == EJson::String)
{
return SetText(JsonValue.AsString(), Errors);
}
if (JsonValue.Type == EJson::Number)
{
return SetDouble(JsonValue.AsNumber(), Errors);
}
if (JsonValue.Type == EJson::Boolean)
{
return SetBool(JsonValue.AsBool(), Errors);
}
if (JsonValue.Type == EJson::Object)
{
FStructProperty* StructProp = CastField<FStructProperty>(Prop);
if (StructProp && (StructProp->Struct == FWingJsonObject::StaticStruct()))
{
FWingJsonObject Val;
Val.Json = JsonValue.AsObject();
Prop->SetValue_InContainer(Container, &Val);
return true;
}
PrintExpectsReceived(TEXT("json object"), Errors);
return false;
}
if (JsonValue.Type == EJson::Array)
{
FStructProperty* StructProp = CastField<FStructProperty>(Prop);
if (StructProp && (StructProp->Struct == FWingJsonArray::StaticStruct()))
{
FWingJsonArray Val;
Val.Array = JsonValue.AsArray();
Prop->SetValue_InContainer(Container, &Val);
return true;
}
PrintExpectsReceived(TEXT("json array"), Errors);
return false;
}
PrintExpectsReceived(TEXT("Unrecognized Json Data"), Errors);
return false;
}
TOptional<UObject*> FWingProperty::GetObject(WingOut Errors) const TOptional<UObject*> FWingProperty::GetObject(WingOut Errors) const
{ {
FObjectPropertyBase *OProp = CastField<FObjectPropertyBase>(Prop); FObjectPropertyBase *OProp = CastField<FObjectPropertyBase>(Prop);
@@ -493,56 +442,54 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
return Result; return Result;
} }
bool FWingProperty::PopulateFromArgv(TArray<FWingProperty>& Props, TConstArrayView<FString> Argv, WingOut Errors)
bool FWingProperty::PopulateFromJson(TArray<FWingProperty>& Props, const FJsonObject& Json, bool AllOptional, WingOut Errors)
{ {
bool Ok = true; int32 ArgIndex = 0;
for (int32 PropIndex = 0; PropIndex < Props.Num(); ++PropIndex)
// Build a set of known property names for the unknown-field check.
TSet<FName> KnownKeys;
for (const FWingProperty& P : Props) KnownKeys.Add(P->GetFName());
// Check for unknown fields in the JSON
for (const auto& KV : Json.Values)
{ {
FName Name = WingUtils::CheckInternalizeID(KV.Key, Errors); FWingProperty& P = Props[PropIndex];
if (!KnownKeys.Contains(Name)) FStructProperty* StructProp = CastField<FStructProperty>(P.Prop);
{ const bool bIsRest =
Errors.Printf(TEXT("ERROR: Unknown parameter '%s'\n"), *KV.Key); StructProp && (StructProp->Struct == FWingRestOfArgv::StaticStruct());
Ok = false;
}
}
// Populate each property from JSON if (bIsRest)
for (FWingProperty& P : Props)
{
FString JsonKey = WingUtils::FormatName(P.Prop);
TSharedPtr<FJsonValue> Value = Json.TryGetField(JsonKey);
if (!Value)
{ {
bool Optional = AllOptional || P.Prop->HasMetaData(TEXT("Optional")); if (PropIndex + 1 != Props.Num())
if (!Optional)
{ {
Errors.Printf(TEXT("ERROR: Missing required parameter '%s'\n"), *JsonKey); Errors.Printf(TEXT("ERROR: '%s' must be the last parameter\n"),
Ok = false; *WingUtils::FormatName(P.Prop));
return false;
} }
FWingRestOfArgv Rest;
for (int32 I = ArgIndex; I < Argv.Num(); ++I)
{
Rest.Argv.Add(Argv[I]);
}
P.Prop->SetValue_InContainer(P.Container, &Rest);
ArgIndex = Argv.Num();
continue; continue;
} }
if (!P.SetJson(*Value, Errors)) Ok = false;
}
return Ok;
}
bool FWingProperty::PopulateFromJson(TArray<FWingProperty>& Props, const FJsonValue& Json, bool AllOptional, WingOut Errors) if (ArgIndex >= Argv.Num())
{ {
// Make sure they passed in a JSON object. Errors.Printf(TEXT("ERROR: Missing parameter '%s'\n"),
TSharedPtr<FJsonObject> Obj = Json.AsObject(); *WingUtils::FormatName(P.Prop));
if (Obj == nullptr) return false;
}
if (!P.SetText(Argv[ArgIndex], Errors)) return false;
ArgIndex++;
}
if (ArgIndex < Argv.Num())
{ {
Errors.Printf(TEXT("property data should be stored in a json object\n")); Errors.Printf(TEXT("ERROR: Too many parameters, starting with '%s'\n"),
*Argv[ArgIndex]);
return false; return false;
} }
return PopulateFromJson(Props, *Obj, AllOptional, Errors);
return true;
} }

View File

@@ -81,7 +81,7 @@ void UWingServer::Deinitialize()
bShuttingDown = true; bShuttingDown = true;
for (auto& Msg : PendingMessages) for (auto& Msg : PendingMessages)
{ {
Msg->Response.SetValue(FString()); Msg->Response.SetValue(TArray<uint8>());
} }
PendingMessages.Empty(); PendingMessages.Empty();
} }
@@ -150,7 +150,7 @@ void UWingServer::Tick(float DeltaTime)
// If we have a request, process it. // If we have a request, process it.
if (Request.IsValid()) if (Request.IsValid())
{ {
FString Response = HandleRequest(Request->Line); TArray<uint8> Response = HandleRequest(Request->Request);
Request->Response.SetValue(Response); Request->Response.SetValue(Response);
} }
} }
@@ -169,71 +169,24 @@ TStatId UWingServer::GetStatId() const
// HandleRequest — Given a command, execute it. // HandleRequest — Given a command, execute it.
// ============================================================ // ============================================================
FString UWingServer::HandleRequest(const FString& Line) TArray<uint8> UWingServer::HandleRequest(const TArray<uint8>& RequestBytes)
{ {
// Parse the request as JSON before doing anything else. TArray<FString> Argv;
TSharedPtr<FJsonValue> Value; FString ResponseText;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Line);
if (!FJsonSerializer::Deserialize(Reader, Value))
return PackageResponses({TEXT("Invalid Json")});
const TSharedPtr<FJsonObject>* RequestPtr = nullptr;
if (!Value->TryGetObject(RequestPtr))
return PackageResponses({TEXT("Json must be an object")});
TSharedPtr<FJsonObject> Request = *RequestPtr;
FString Command; if (DeserializeArgv(RequestBytes, Argv))
Request->TryGetStringField(TEXT("command"), Command);
if (Command == TEXT("Sequence"))
{ {
const TArray<TSharedPtr<FJsonValue>>* Subcommands = nullptr; PreCallHandler();
if (!Request->TryGetArrayField(TEXT("subcommands"), Subcommands)) TryCallHandler(Argv);
return PackageResponses({TEXT("Sequence requires a 'subcommands' array.")}); ResponseText = PostCallHandler();
TArray<FString> Responses;
Responses.Reserve(Subcommands->Num());
for (const TSharedPtr<FJsonValue>& Sub : *Subcommands)
{
const TSharedPtr<FJsonObject>* SubObjPtr = nullptr;
if (!Sub->TryGetObject(SubObjPtr))
Responses.Add(TEXT("Subcommand must be a JSON object."));
else
Responses.Add(HandleJsonRequest(*SubObjPtr));
}
return PackageResponses(Responses);
} }
else ResponseText = TEXT("Invalid argv encoding (bug in ue-wingman.py)\n");
return PackageResponses({HandleJsonRequest(Request)}); FTCHARToUTF8 Utf8(*ResponseText);
return TArray<uint8>(reinterpret_cast<const uint8*>(Utf8.Get()), Utf8.Length());
} }
FString UWingServer::PackageResponses(const TArray<FString>& Responses) void UWingServer::PreCallHandler()
{
TArray<TSharedPtr<FJsonValue>> Blocks;
Blocks.Reserve(Responses.Num());
for (const FString& Response : Responses)
{
// Unreal's JSON writer terminates string serialization at the first
// embedded null byte rather than escaping it, which would silently
// truncate output. Sanitize null bytes to spaces.
FString Sanitized = Response;
for (int32 i = 0; i < Sanitized.Len(); ++i)
{
if (Sanitized[i] == TEXT('\0')) Sanitized[i] = TEXT(' ');
}
TSharedPtr<FJsonObject> Block = MakeShared<FJsonObject>();
Block->SetStringField(TEXT("type"), TEXT("text"));
Block->SetStringField(TEXT("text"), Sanitized);
Blocks.Add(MakeShared<FJsonValueObject>(Block));
}
FString OutJson;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutJson);
FJsonSerializer::Serialize(Blocks, Writer);
return OutJson;
}
FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
{ {
LogCapture.CapturedErrors.Empty(); LogCapture.CapturedErrors.Empty();
LogCapture.bEnabled = true; LogCapture.bEnabled = true;
@@ -241,9 +194,10 @@ FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
SuggestedManualSections.Empty(); SuggestedManualSections.Empty();
bSuggestHandlerHelp = false; bSuggestHandlerHelp = false;
LastHandler = nullptr; LastHandler = nullptr;
}
TryCallHandler(Request); FString UWingServer::PostCallHandler()
{
Notifier.SendNotifications(); Notifier.SendNotifications();
LogCapture.bEnabled = false; LogCapture.bEnabled = false;
for (const FString& Msg : LogCapture.CapturedErrors) for (const FString& Msg : LogCapture.CapturedErrors)
@@ -269,17 +223,15 @@ FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
return Result; return Result;
} }
void UWingServer::TryCallHandler(TSharedPtr<FJsonObject> Request) void UWingServer::TryCallHandler(const TArray<FString>& Argv)
{ {
// Extract the command from the request. if (Argv.Num() < 1)
FString Command;
if (!Request->TryGetStringField(TEXT("command"), Command))
{ {
WingOut::Stdout.Printf(TEXT("Request does not contain 'command' parameter")); WingOut::Stdout.Print(TEXT("Missing command\n"));
WingOut::Stdout.Printf(TEXT("We recommend sending command='Documentation_Manual'."));
return; return;
} }
Request->RemoveField(TEXT("command"));
FString Command = Argv[0];
// Find the handler for the specified command. // Find the handler for the specified command.
FWingHandlerConfig* Found = FindHandler(Command); FWingHandlerConfig* Found = FindHandler(Command);
@@ -296,9 +248,9 @@ void UWingServer::TryCallHandler(TSharedPtr<FJsonObject> Request)
UWingHandler* Handler = Cast<UWingHandler>(HandlerObj.Get()); UWingHandler* Handler = Cast<UWingHandler>(HandlerObj.Get());
Handler->Configuration = Found; Handler->Configuration = Found;
// Populate the handler object with the request parameters. // Populate the handler object with argv parameters.
TArray<FWingProperty> Props = FWingProperty::GetVisible(Handler, true); TArray<FWingProperty> Props = FWingProperty::GetVisible(Handler, true);
if (!FWingProperty::PopulateFromJson(Props, *Request, false, WingOut::Stdout)) if (!FWingProperty::PopulateFromArgv(Props, MakeArrayView(Argv).RightChop(1), WingOut::Stdout))
{ {
UWingServer::SuggestHandlerHelp(); UWingServer::SuggestHandlerHelp();
return; return;
@@ -356,102 +308,105 @@ void UWingServer::CleanupFinishedClients()
void UWingServer::ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client) void UWingServer::ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client)
{ {
constexpr int32 MaxRecvBufBytes = 1024 * 1024;
constexpr int32 MinUnusedRecvSpace = 4096;
FSocket* Socket = Client->Socket; FSocket* Socket = Client->Socket;
TArray<uint8> RecvBuf;
RecvBuf.SetNumUninitialized(MinUnusedRecvSpace);
int32 RecvLen = 0;
WaitForAssetRegistry(); WaitForAssetRegistry();
while (true) TArray<uint8> Request;
if (!ReceiveRequest(Socket, Request))
{ {
FString Request; Client->bDone = true;
if (ExtractRequestFromBuffer(RecvBuf, RecvLen, Request)) return;
{
FString Response;
if (!ProcessRequestOnGameThread(Request, Response))
{
Client->bDone = true;
return;
}
// Write the response back, null-terminated (blocking)
FTCHARToUTF8 Utf8(*Response);
if (!SendAll(Socket, reinterpret_cast<const uint8*>(Utf8.Get()),
Utf8.Length() + 1))
{
Client->bDone = true;
return;
}
continue;
}
if (!ReceiveMoreBytesIntoBuffer(Socket, RecvBuf, RecvLen))
{
break;
}
} }
TArray<uint8> Response;
if (!ProcessRequestOnGameThread(Request, Response))
{
Client->bDone = true;
return;
}
SendAll(Socket, Response.GetData(), Response.Num());
Client->bDone = true; Client->bDone = true;
} }
bool UWingServer::ExtractRequestFromBuffer( uint32 UWingServer::UnpackBigEndian(const uint8 *Data)
TArray<uint8>& RecvBuf, int32& RecvLen, FString& OutRequest)
{ {
const uint8* EndOfRequest = static_cast<const uint8*>( return
memchr(RecvBuf.GetData(), '\0', RecvLen)); ((uint32)Data[0] << 24) |
if (EndOfRequest == nullptr) ((uint32)Data[1] << 16) |
((uint32)Data[2] << 8) |
(uint32)Data[3];
}
bool UWingServer::DeserializeArgv(
const TArray<uint8>& RequestBytes, TArray<FString>& Argv)
{
Argv.Empty();
int32 Offset = 0;
while (Offset < RequestBytes.Num())
{ {
return false; if (RequestBytes.Num() - Offset < 4)
{
Argv.Empty();
return false;
}
uint32 Length = UnpackBigEndian(RequestBytes.GetData() + Offset);
Offset += 4;
if ((uint32)(RequestBytes.Num() - Offset) < Length)
{
Argv.Empty();
return false;
}
Argv.Add(FString::ConstructFromPtrSize(
reinterpret_cast<const UTF8CHAR*>(RequestBytes.GetData() + Offset),
Length));
Offset += (int32)Length;
} }
const int32 MessageLen =
static_cast<int32>(EndOfRequest - RecvBuf.GetData());
OutRequest = FString::ConstructFromPtrSize(
reinterpret_cast<const UTF8CHAR*>(RecvBuf.GetData()), MessageLen);
const int32 RemainingBytes = RecvLen - (MessageLen + 1);
if (RemainingBytes > 0)
{
FMemory::Memmove(
RecvBuf.GetData(),
RecvBuf.GetData() + MessageLen + 1,
RemainingBytes);
}
RecvLen = RemainingBytes;
return true; return true;
} }
bool UWingServer::ReceiveMoreBytesIntoBuffer( bool UWingServer::ReceiveRequest(FSocket* Socket, TArray<uint8>& OutRequest)
FSocket* Socket, TArray<uint8>& RecvBuf, int32& RecvLen)
{ {
constexpr int32 MaxRecvBufBytes = 1024 * 1024; constexpr int32 MaxRecvBufBytes = 1024 * 1024;
constexpr int32 MinUnusedRecvSpace = 4096; constexpr int32 ChunkSize = 8192;
int32 UnusedSpace = RecvBuf.Num() - RecvLen; TArray<uint8> RecvBuf;
if (UnusedSpace < MinUnusedRecvSpace) RecvBuf.Reserve(ChunkSize);
// Unreal's FSocket API is fundamentally broken: recv cannot
// differentiate between a socket that has been cleanly closed
// and a socket that has had an error. So we have no choice
// but to just read until recv returns false (which could be a
// clean close or an error). Then, we check if we have a cleanly
// encoded payload: if so, we assume everything is fine.
while (true)
{ {
if (RecvBuf.Num() >= MaxRecvBufBytes) uint8 Temp[ChunkSize];
int32 BytesRead = 0;
if (!Socket->Recv(Temp, ChunkSize, BytesRead))
{
break;
}
if (BytesRead <= 0) break;
if (RecvBuf.Num() + BytesRead > MaxRecvBufBytes)
{ {
return false; return false;
} }
RecvBuf.SetNumUninitialized(RecvBuf.Num() * 2); RecvBuf.Append(Temp, BytesRead);
UnusedSpace = RecvBuf.Num() - RecvLen;
} }
int32 BytesRead = 0; if (RecvBuf.Num() < 4) return false;
if (!Socket->Recv(RecvBuf.GetData() + RecvLen, UnusedSpace, BytesRead)) uint32 Size = UnpackBigEndian(RecvBuf.GetData());
{ if ((uint32)RecvBuf.Num() != (4u + Size)) return false;
return false; RecvBuf.RemoveAt(0, 4);
}
if (BytesRead <= 0)
{
return false;
}
RecvLen += BytesRead; OutRequest = MoveTemp(RecvBuf);
return true; return true;
} }
@@ -471,13 +426,13 @@ bool UWingServer::SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend)
} }
bool UWingServer::ProcessRequestOnGameThread( bool UWingServer::ProcessRequestOnGameThread(
const FString& Request, FString& Response) const TArray<uint8>& Request, TArray<uint8>& Response)
{ {
// Enqueue the message for game-thread processing. // Enqueue the message for game-thread processing.
TSharedPtr<UWingServer::FPendingMessage> Msg = TSharedPtr<UWingServer::FPendingMessage> Msg =
MakeShared<UWingServer::FPendingMessage>(); MakeShared<UWingServer::FPendingMessage>();
Msg->Line = Request; Msg->Request = Request;
TFuture<FString> Future = Msg->Response.GetFuture(); TFuture<TArray<uint8>> Future = Msg->Response.GetFuture();
{ {
FScopeLock Lock(&GWingServer->Mutex); FScopeLock Lock(&GWingServer->Mutex);

View File

@@ -82,7 +82,7 @@ bool WingVariableList::CheckSanity(const TSet<FName> &GoodFlags, bool Allow, Win
{ {
if ((!Allow) && (!Variables.IsEmpty())) if ((!Allow) && (!Variables.IsEmpty()))
{ {
Errors.Printf(TEXT("In this context, %s must be empty."), ListName); Errors.Printf(TEXT("This object does not support %s.\n"), ListName);
return false; return false;
} }
for (const Var &Variable : Variables) for (const Var &Variable : Variables)
@@ -112,116 +112,6 @@ bool WingVariableList::CheckSanity(const TSet<FName> &GoodFlags, bool Allow, Win
return true; return true;
} }
bool WingVariableList::ParseString(const FString &Input, WingOut Errors)
{
Variables.Empty();
TArray<FString> Lines;
Input.ParseIntoArrayLines(Lines);
for (const FString& Line : Lines)
{
WingTokenizer Tok(Line);
if (Tok.NextType() == 0) continue;
Var V;
V.DefaultSpecified = false;
if (!ParseOneVariable(Tok, V, Errors)) return false;
Variables.Add(MoveTemp(V));
}
return true;
}
bool WingVariableList::ParseNamesString(const FString &Input, WingOut Errors)
{
Variables.Empty();
WingTokenizer Tok(Input);
while (Tok.TokenIs(Tok.Identifier))
{
FName Name = Tok.NextName();
Var V;
V.Name = Name;
Variables.Add(V);
V.DefaultSpecified = false;
Tok.Advance();
if (Tok.TokenIs(',')) Tok.Advance();
}
if (!Tok.TokenIs(0))
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("Unexpected token %s in variable list"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
return true;
}
bool WingVariableList::ParseOneVariable(WingTokenizer &Tok, Var &V, WingOut Errors)
{
// Parse type.
UWingTypes::Requirements Req;
Req.BlueprintType = true;
Req.Blueprintable = false;
Req.AllowContainer = true;
if (!UWingTypes::TextToType(Tok, V.Type, Req, false, Errors))
return false;
// Parse name.
if (Tok.NextType() != Tok.Identifier)
{
Errors.Print(TEXT("ERROR: Expected variable name after type\n"));
return false;
}
V.Name = Tok.NextName();
Tok.Advance();
// Parse optional flags: (flag1, flag2, ...)
if (Tok.TokenIs('('))
{
if (!ParseVariableFlags(Tok, V.Flags, Errors)) return false;
}
// Parse optional default value: = rest-of-line
if (Tok.NextType() == Tok.RestOfLine)
{
V.DefaultSpecified = true;
V.DefaultValue = FString(Tok.NextRest().TrimStartAndEnd());
Tok.Advance();
}
// Should be at end of line.
if (Tok.NextType() != 0)
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: Unexpected token after variable declaration: '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
return true;
}
bool WingVariableList::ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors)
{
Tok.Advance(); // Step over open-paren
while (Tok.TokenIs(Tok.Identifier))
{
Out.Add(Tok.NextName());
Tok.Advance();
// Commas are optional.
if (Tok.TokenIs(',')) Tok.Advance();
}
if (!Tok.TokenIs(')'))
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: flag list contains invalid token '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
Tok.Advance(); // Step over close-paren
return true;
}
void WingVariables::Empty() void WingVariables::Empty()
{ {
BlueprintVariables.Empty(); BlueprintVariables.Empty();
@@ -254,6 +144,107 @@ void WingVariables::Print(WingOut Out)
OutputVariables.Print(Out); OutputVariables.Print(Out);
} }
WingVariableList *WingVariables::GetList(FName Name)
{
if (Name == TEXT("blueprint")) return &BlueprintVariables;
if (Name == TEXT("input")) return &InputVariables;
if (Name == TEXT("output")) return &OutputVariables;
if (Name == TEXT("local")) return &LocalVariables;
return nullptr;
}
bool WingVariables::ParseOneVariable(WingTokenizer &Tok, FName &Kind, Var &V, bool NameOnly, WingOut Errors)
{
// Parse Kind.
if (GetList(Tok.NextName()) == nullptr)
{
Errors.Print(TEXT("ERROR: Variable description should start with 'blueprint', 'input', 'output', or 'local'"));
return false;
}
Kind = Tok.NextName();
Tok.Advance();
// Parse type.
if (!NameOnly)
{
UWingTypes::Requirements Req;
Req.BlueprintType = true;
Req.Blueprintable = false;
Req.AllowContainer = true;
if (!UWingTypes::TextToType(Tok, V.Type, Req, false, Errors))
return false;
}
// Parse name.
if (Tok.NextType() != Tok.Identifier)
{
Errors.Print(TEXT("ERROR: Expected variable name after type\n"));
return false;
}
V.Name = Tok.NextName();
Tok.Advance();
// Parse optional flags: (flag1, flag2, ...)
if ((!NameOnly) && Tok.TokenIs('('))
{
if (!ParseVariableFlags(Tok, V.Flags, Errors)) return false;
}
// Parse optional default value: = rest-of-line
if (!NameOnly && (Tok.NextType() == Tok.RestOfLine))
{
V.DefaultSpecified = true;
V.DefaultValue = FString(Tok.NextRest().TrimStartAndEnd());
Tok.Advance();
}
// Should be at end of line.
if (Tok.NextType() != 0)
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: Unexpected token after variable declaration: '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
return true;
}
bool WingVariables::ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors)
{
Tok.Advance(); // Step over open-paren
while (Tok.TokenIs(Tok.Identifier))
{
Out.Add(Tok.NextName());
Tok.Advance();
// Commas are optional.
if (Tok.TokenIs(',')) Tok.Advance();
}
if (!Tok.TokenIs(')'))
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: flag list contains invalid token '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
Tok.Advance(); // Step over close-paren
return true;
}
bool WingVariables::Parse(const TArray<FString> &Vars, bool NameOnly, WingOut Errors)
{
for (const FString& Onevar : Vars)
{
WingTokenizer Tok(Onevar);
FName Kind;
Var V;
if (!ParseOneVariable(Tok, Kind, V, NameOnly, Errors)) return false;
WingVariableList *List = GetList(Kind);
List->Add(V);
}
return true;
}
void WingVariables::Load(WingOut Errors) void WingVariables::Load(WingOut Errors)
{ {
Empty(); Empty();

View File

@@ -62,32 +62,18 @@ public:
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
// //
// Json wrappers. // A simple type to store the remaining arguments in
// // an Argv Array.
// Normally, the json request is automatically used to
// populate the properties of the handler, so the handler
// doesn't have to deal with json. However, in a few cases,
// the handler actually does want to see some json. These
// wrappers allow a handler to request raw json data instead
// of pre-processed values.
// //
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
USTRUCT() USTRUCT()
struct FWingJsonObject struct FWingRestOfArgv
{ {
GENERATED_BODY() GENERATED_BODY()
TSharedPtr<FJsonObject> Json;
};
// Marker struct for handler parameters that accept a JSON array. UPROPERTY()
// PopulateFromJson stashes the actual JSON array into the Array field. TArray<FString> Argv;
//
USTRUCT()
struct FWingJsonArray
{
GENERATED_BODY()
TArray<TSharedPtr<FJsonValue>> Array;
}; };
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
@@ -200,3 +186,4 @@ public:
bool Editable; bool Editable;
}; };

View File

@@ -41,7 +41,6 @@ struct FWingProperty
bool SetInt64(int64 I, WingOut Errors) const; bool SetInt64(int64 I, WingOut Errors) const;
bool SetBool(bool B, WingOut Errors) const; bool SetBool(bool B, WingOut Errors) const;
bool SetText(FString Value, WingOut Errors) const; bool SetText(FString Value, WingOut Errors) const;
bool SetJson(const FJsonValue &Value, WingOut Errors) const;
// Fetch a value. If an error occurs such as a type // Fetch a value. If an error occurs such as a type
// mismatch, returns an empty optional and prints an // mismatch, returns an empty optional and prints an
@@ -86,6 +85,7 @@ struct FWingProperty
// If mutable is false, all properties will be marked non-editable. // If mutable is false, all properties will be marked non-editable.
// //
static TArray<FWingProperty> GetVisible(UObject *Obj, void *Container, UStruct *Struct, bool Mutable); static TArray<FWingProperty> GetVisible(UObject *Obj, void *Container, UStruct *Struct, bool Mutable);
static bool PopulateFromArgv(TArray<FWingProperty>& Props, TConstArrayView<FString> Argv, WingOut Errors);
// Convenience versions of GetAll and GetVisible for UObjects. // Convenience versions of GetAll and GetVisible for UObjects.
// //
@@ -121,16 +121,6 @@ struct FWingProperty
// //
static TArray<FWingProperty> GetDetails(UObject* Obj, bool Mutable); static TArray<FWingProperty> GetDetails(UObject* Obj, bool Mutable);
// Functions to populate properties from a JSON object.
//
static bool PopulateFromJson(TArray<FWingProperty>& Props, const FJsonObject& Json,
bool AllOptional, WingOut Errors);
static bool PopulateFromJson(TArray<FWingProperty>& Props, const FJsonValue& Json,
bool AllOptional, WingOut Errors);
// Functions to populate properties from a JSON object.
//
private: private:
static bool IsUnsigned(FNumericProperty* Prop); static bool IsUnsigned(FNumericProperty* Prop);
static bool IsPinTypeProperty(FProperty *Prop); static bool IsPinTypeProperty(FProperty *Prop);

View File

@@ -62,9 +62,6 @@ public:
static void AddHandler(UObject* Obj, const FString& Name, UObject* Config, EWingHandlerKind Kind, UClass* FactoryClass, const FString& Documentation); static void AddHandler(UObject* Obj, const FString& Name, UObject* Config, EWingHandlerKind Kind, UClass* FactoryClass, const FString& Documentation);
static const TArray<FWingHandlerConfig>& AllHandlers() { return GWingServer->WingHandlerRegistry; } static const TArray<FWingHandlerConfig>& AllHandlers() { return GWingServer->WingHandlerRegistry; }
/** Package a list of response texts into a single serialized JSON content-block array. */
static FString PackageResponses(const TArray<FString>& Responses);
private: private:
static UWingServer* GWingServer; static UWingServer* GWingServer;
@@ -79,10 +76,11 @@ private:
FDelegateHandle LoadingPhasesCompleteHandle; FDelegateHandle LoadingPhasesCompleteHandle;
FWingHandlerConfig* FindHandler(const FString& Name); FWingHandlerConfig* FindHandler(const FString& Name);
// Handle a complete JSON line and return the response JSON // Handle a complete request and return the response bytes.
FString HandleRequest(const FString& Line); TArray<uint8> HandleRequest(const TArray<uint8>& RequestBytes);
FString HandleJsonRequest(TSharedPtr<FJsonObject> Request); void PreCallHandler();
void TryCallHandler(TSharedPtr<FJsonObject> Request); FString PostCallHandler();
void TryCallHandler(const TArray<FString>& Argv);
// ----- TCP server ----- // ----- TCP server -----
FSocket* ListenSocket = nullptr; FSocket* ListenSocket = nullptr;
@@ -99,22 +97,23 @@ private:
TArray<TSharedPtr<FClientConnection>> Clients; TArray<TSharedPtr<FClientConnection>> Clients;
void AcceptNewConnections(); void AcceptNewConnections();
void CleanupFinishedClients(); void CleanupFinishedClients();
static uint32 UnpackBigEndian(const uint8 *Data);
static bool DeserializeArgv(
const TArray<uint8>& RequestBytes, TArray<FString>& Argv);
static void ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client); static void ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client);
static bool ExtractRequestFromBuffer( static bool ReceiveRequest(
TArray<uint8>& RecvBuf, int32& RecvLen, FString& OutRequest); FSocket* Socket, TArray<uint8>& OutRequest);
static bool ReceiveMoreBytesIntoBuffer(
FSocket* Socket, TArray<uint8>& RecvBuf, int32& RecvLen);
static bool SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend); static bool SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend);
static bool ProcessRequestOnGameThread( static bool ProcessRequestOnGameThread(
const FString& Request, FString& Response); const TArray<uint8>& Request, TArray<uint8>& Response);
static void WaitForAssetRegistry(); static void WaitForAssetRegistry();
// ----- The Critical Section ----- // ----- The Critical Section -----
struct FPendingMessage struct FPendingMessage
{ {
FString Line; TArray<uint8> Request;
TPromise<FString> Response; TPromise<TArray<uint8>> Response;
FPendingMessage() : Response(TPromise<FString>()) {} FPendingMessage() : Response(TPromise<TArray<uint8>>()) {}
}; };
FCriticalSection Mutex; FCriticalSection Mutex;
TArray<TSharedPtr<FPendingMessage>> PendingMessages; TArray<TSharedPtr<FPendingMessage>> PendingMessages;

View File

@@ -57,6 +57,9 @@ public:
// Empty the variable list. // Empty the variable list.
void Empty() { Variables.Empty(); } void Empty() { Variables.Empty(); }
// Add a variable.
void Add(const Var &Var) { Variables.Add(Var); }
// Return true if the variables are empty. // Return true if the variables are empty.
bool IsEmpty() { return Variables.IsEmpty(); } bool IsEmpty() { return Variables.IsEmpty(); }
@@ -72,21 +75,11 @@ public:
// Check the sanity of the vars in the array. If allow // Check the sanity of the vars in the array. If allow
// is false, then no variables are allowed in the array. // is false, then no variables are allowed in the array.
bool CheckSanity(const TSet<FName> &GoodFlags, bool Allow, WingOut Errors); bool CheckSanity(const TSet<FName> &GoodFlags, bool Allow, WingOut Errors);
// Parse variables from a string.
bool ParseString(const FString &Input, WingOut Errors);
// Parse variable names only from a string.
bool ParseNamesString(const FString &Input, WingOut Errors);
private:
bool ParseOneVariable(WingTokenizer &Tok, Var &V, WingOut Errors);
bool ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors);
}; };
class WingVariables class WingVariables
{ {
public: public:
using Var = WingVariableList::Var; using Var = WingVariableList::Var;
WingVariables() {} WingVariables() {}
@@ -125,6 +118,10 @@ public:
void Print(WingOut Out); void Print(WingOut Out);
// Parse variables.
bool Parse(const TArray<FString> &Vars, bool NameOnly, WingOut Errors);
// Load: clear the workspace, then // Load: clear the workspace, then
// copy everything from the backing store into the workspace. // copy everything from the backing store into the workspace.
@@ -193,4 +190,8 @@ private:
void AddUserPinInfo(const Var &V, EEdGraphPinDirection Dir, UK2Node_EditablePinBase *Node); void AddUserPinInfo(const Var &V, EEdGraphPinDirection Dir, UK2Node_EditablePinBase *Node);
bool ErrorNoBackingStore(WingOut Errors); bool ErrorNoBackingStore(WingOut Errors);
bool ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors);
bool ParseOneVariable(WingTokenizer &Tok, FName &Kind, Var &V, bool NameOnly, WingOut Errors);
WingVariableList *GetList(FName Name);
}; };

View File

@@ -1,178 +0,0 @@
#!/usr/bin/env python3
"""
MCP stdio-to-TCP bridge for UE Wingman.
Exposes a single MCP tool "unreal" that forwards JSON commands to the
UE Wingman TCP server in the Unreal Editor.
"""
import sys
import json
import socket
HOST = "localhost"
PORT = 9851
CONNECT_TIMEOUT = 2
READ_TIMEOUT = 30
TOOL_DESCRIPTION = (
"Send a command to the Unreal Editor's UE Wingman plugin. "
"The 'command' field specifies which operation to perform; "
"additional fields are command-specific parameters. "
'Use {"command": "Documentation_Manual"} to get an overview. '
"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
def connect():
"""Try to connect to the editor. Returns True on success."""
global sock
if sock is not None:
return True
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(CONNECT_TIMEOUT)
s.connect((HOST, PORT))
s.settimeout(READ_TIMEOUT)
sock = s
return True
except (ConnectionRefusedError, socket.timeout, OSError):
return False
def disconnect():
global sock
if sock is not None:
try:
sock.close()
except Exception:
pass
sock = None
def send_and_receive(message):
"""Send a JSON message to the editor and return the null-terminated response."""
data = json.dumps(message) + "\0"
sock.sendall(data.encode())
result = b""
while True:
chunk = sock.recv(65536)
if not chunk:
raise ConnectionError("Connection closed")
result += chunk
if b"\0" in result:
break
return result[:result.index(b"\0")].decode()
def forward_to_editor(arguments):
"""Forward arguments to the editor, return the result dict."""
if not connect():
return {"error": "Unreal Editor is not running. Start the editor and try again."}
try:
return send_and_receive(arguments)
except Exception:
disconnect()
return {"error": "Lost connection to Unreal Editor."}
def make_jsonrpc(msg_id, result):
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def parse_editor_response(result):
"""Parse and validate a raw editor response into an MCP content list.
MCP expects `content` to be a list of objects, each with at least a
string "type" field (e.g. {"type": "text", "text": "..."}). Anything
else is replaced with a single error item so the client sees a clear
message instead of a schema violation.
"""
try:
parsed = json.loads(result)
except json.JSONDecodeError:
return [{"type": "text", "text": "Malformed response from editor: invalid JSON."}]
if not isinstance(parsed, list):
return [{"type": "text", "text": "Malformed response from editor: expected a list."}]
for item in parsed:
if not isinstance(item, dict):
return [{"type": "text", "text": "Malformed response from editor: list item is not an object."}]
if not isinstance(item.get("type"), str):
return [{"type": "text", "text": "Malformed response from editor: item missing string 'type' field."}]
return parsed
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": "ue-wingman", "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)
if isinstance(result, dict) and "error" in result:
content = [{"type": "text", "text": result["error"]}]
else:
content = parse_editor_response(result)
return make_jsonrpc(msg_id, {
"content": content,
})
return {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
response = handle_message(msg)
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()

View File

@@ -1,37 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Human-friendly MCP test client. UE Wingman command-line tool. This tool simply packages up its
argv and sends it to the plugin, and then prints whatever the
plugin sends back. All the real work is done in the plugin.
Usage: ue-wingman.py <command> [key=value ...] Usage: ue-wingman.py <arg1> [arg2 ...]
Values starting with '[' or '{' are parsed as JSON.
""" """
import sys import sys
import json
import socket import socket
import struct
HOST = "localhost" HOST = "localhost"
PORT = 9851 PORT = 9851
TIMEOUT = 120 TIMEOUT = 15
def main(): def main():
args = sys.argv[1:] args = sys.argv[1:]
if not args:
print("Usage: ue-wingman.py <command> [key=value ...]")
sys.exit(1)
msg = {"command": args[0]}
for arg in args[1:]:
key, _, value = arg.partition("=")
if value and value[0] in ('[', '{'):
try:
value = json.loads(value)
except json.JSONDecodeError as e:
print(f"Bad JSON in {key}: {e.msg}")
sys.exit(1)
msg[key] = value
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(TIMEOUT) sock.settimeout(TIMEOUT)
@@ -41,7 +27,14 @@ def main():
print(f"Cannot connect to {HOST}:{PORT} — is the editor running?") print(f"Cannot connect to {HOST}:{PORT} — is the editor running?")
sys.exit(1) sys.exit(1)
sock.sendall((json.dumps(msg) + "\0").encode()) payload = bytearray()
for arg in args:
data = arg.encode()
payload += struct.pack("!I", len(data))
payload += data
sock.sendall(struct.pack("!I", len(payload)))
sock.sendall(payload)
sock.shutdown(socket.SHUT_WR)
result = b"" result = b""
while True: while True:
@@ -49,29 +42,9 @@ def main():
if not chunk: if not chunk:
break break
result += chunk result += chunk
if b"\0" in result:
break
sock.close() sock.close()
result = result[:result.index(b"\0")].decode() if b"\0" in result else result.decode() print(result.decode(), end="")
try:
parsed = json.loads(result)
except json.JSONDecodeError:
print("Error: response is not valid JSON.")
sys.exit(1)
if not isinstance(parsed, list):
print("Error: response is not a list of content blocks.")
sys.exit(1)
for block in parsed:
if not (isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)):
print("Error: response contains a non-text block.")
sys.exit(1)
print("\n---\n".join(block["text"] for block in parsed))
if __name__ == "__main__": if __name__ == "__main__":
main() main()