MCP: localhost agent server with reloadable S3.Mcp.Tools plugin pack

Loopback MCP host with observe/control/develop gates and a hot-reload
tools DLL. Default token is local-dev only. Leave the module off unless
you are driving S3 from an agent.
This commit is contained in:
Seton Carmichael 2026-09-11 15:19:24 -04:00
parent 48c9b97029
commit 5e18cb84a1
22 changed files with 3612 additions and 9 deletions

View file

@ -25,6 +25,7 @@ I originally planned on releasing individual mods, but considering my workflow o
| Quick Actions | Extra outer-ring couple/air/cut actions and a consist hover wheel on the rolling-stock pie menu. |
| Car Cards | Fanned consist dock for the selected cut, with waybill, notes, and couple/handbrake/locate actions. |
| Industry Tags | In-world business, track, and yard callouts with live industry data. |
| MCP | Localhost agent server so a coding tool can inspect the live game. Disabled by default. |
All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow.
@ -232,6 +233,16 @@ the strategy camera. Hover highlights the related tracks. Console: `/s3ind dump`
---
## MCP
A loopback-only HTTP server so a local coding agent can observe the live game
and, with gates on, control it or reload tool DLLs. Enable it from the S³
settings page. Default bind is 127.0.0.1. Console: `/s3mcp`.
This is a developer module. Leave it off unless you are driving S³ from an agent.
---
## Migrating from the standalone mods
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and

74
dist/build-common.ps1 vendored
View file

@ -28,6 +28,17 @@ function Invoke-ManagedBuild {
return $dll
}
function Invoke-McpToolsBuild {
param([string]$Configuration = "Release", [string]$GameDir = "")
Write-Host "=== Building MCP tools (S3.Mcp.Tools.dll, $Configuration) ===" -ForegroundColor Cyan
$csproj = Join-Path $RepoRoot "mcp-tools\S3.Mcp.Tools.csproj"
$args = @($csproj, "-c", $Configuration, "--nologo", "-v", "minimal")
if ($GameDir) { $args += "/p:GameDir=$GameDir" }
dotnet build @args | Out-Host
if ($LASTEXITCODE -ne 0) { throw "MCP tools build failed." }
}
function Invoke-NativeBuild {
param([string]$Configuration = "Release")
@ -50,6 +61,51 @@ function Invoke-NativeBuild {
return $dll
}
function Test-ModDllLocked {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path $Path)) { return $false }
try {
[System.IO.File]::Open($Path, 'Open', 'ReadWrite', 'None').Close()
return $false
} catch {
return $true
}
}
function Get-LockedModDlls {
param([Parameter(Mandatory)][string]$DestModDir)
$locked = @()
foreach ($name in @("S3.dll", "S3Native.dll")) {
if (Test-ModDllLocked (Join-Path $DestModDir $name)) { $locked += $name }
}
return $locked
}
# Poll until Mods\S3 DLLs are writable. Railroader.exe is the usual locker;
# we wait on the file itself so a hung Unity process is still detected.
function Wait-ModUnlocked {
param(
[Parameter(Mandatory)][string]$DestModDir,
[int]$PollSeconds = 2
)
$locked = @(Get-LockedModDlls $DestModDir)
if ($locked.Count -eq 0) { return }
Write-Host "=== Waiting for Railroader to close ($($locked -join ', ') locked) ===" -ForegroundColor Yellow
Write-Host " Close the game; install will run automatically. Ctrl+C to abort." -ForegroundColor DarkYellow
while ($true) {
Start-Sleep -Seconds $PollSeconds
$still = @(Get-LockedModDlls $DestModDir)
if ($still.Count -eq 0) {
Write-Host "=== Unlocked, installing ===" -ForegroundColor Green
return
}
$rr = Get-Process -Name "Railroader" -ErrorAction SilentlyContinue
$hint = if ($rr) { "Railroader.exe still running" } else { "$($still -join ', ') still locked" }
Write-Host " ... $hint" -ForegroundColor DarkGray
}
}
# Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself).
#
# Overwrites in place rather than wiping the folder. A full wipe is dangerous: if
@ -60,18 +116,20 @@ function Invoke-NativeBuild {
function New-ModLayout {
param([Parameter(Mandatory)][string]$DestModDir,
[Parameter(Mandatory)][string]$ManagedDll,
[string]$NativeDll = $null)
[string]$NativeDll = $null,
[switch]$Wait)
New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null
if ($Wait) {
Wait-ModUnlocked -DestModDir $DestModDir
}
# Fail fast with a clear message if a DLL we're about to overwrite is locked
# (game still running) — before touching anything.
foreach ($name in @("S3.dll", "S3Native.dll")) {
$path = Join-Path $DestModDir $name
if (Test-Path $path) {
try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() }
catch { throw "$name is locked - close Railroader before installing." }
}
$locked = @(Get-LockedModDlls $DestModDir)
if ($locked.Count -gt 0) {
throw "$($locked -join ', ') is locked - close Railroader before installing (or rerun with -Wait)."
}
# Drop stale artifacts from older builds (the pre-rename native DLL, ngen
@ -82,6 +140,8 @@ function New-ModLayout {
Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force
Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force
$icon = Join-Path $RepoRoot "src\Modules\QuickActions\Icons\consist.png"
if (Test-Path $icon) { Copy-Item $icon (Join-Path $DestModDir "consist.png") -Force }
if ($NativeDll) {
Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force
# Ship ProggyClean.ttf alongside the native DLL so IMGUI_DISABLE_DEFAULT_FONT

22
dist/build-local.ps1 vendored
View file

@ -1,20 +1,38 @@
# Build S³ and install it straight into the local game copy for testing.
# .\dist\build-local.ps1
# .\dist\build-local.ps1 -Wait
# .\dist\build-local.ps1 -ToolsOnly
# .\dist\build-local.ps1 -GameDir "X:\path\to\Railroader" -Configuration Debug
#
# -Wait: build now, then poll until Railroader releases Mods\S3\*.dll and copy.
# Use this when the game is open (S3Native.dll stays locked until quit).
#
# -ToolsOnly: rebuild the reloadable MCP tools DLL and copy it to
# Mods\S3\plugins\ while the game is running. The MCP host auto-reloads it.
param(
[string]$Configuration = "Release",
[string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader"
[string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader",
[switch]$Wait,
[switch]$ToolsOnly
)
. (Join-Path $PSScriptRoot "build-common.ps1")
if (-not (Test-Path $GameDir)) { throw "GameDir not found: $GameDir" }
if ($ToolsOnly) {
Invoke-McpToolsBuild -Configuration $Configuration -GameDir $GameDir
Write-Host "=== MCP tools copied to $GameDir\Mods\S3\plugins ===" -ForegroundColor Green
Write-Host " Host auto-reloads S3.Mcp.Tools.dll (or /s3mcp reload)."
return
}
$managed = Invoke-ManagedBuild -Configuration $Configuration -GameDir $GameDir
$native = Invoke-NativeBuild -Configuration $Configuration
$modDir = Join-Path $GameDir "Mods\$ModId"
New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native
New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native -Wait:$Wait
Invoke-McpToolsBuild -Configuration $Configuration -GameDir $GameDir
Write-Host "=== Installed to $modDir ===" -ForegroundColor Green

7
dist/build-mcp-tools.ps1 vendored Normal file
View file

@ -0,0 +1,7 @@
# Rebuild only the reloadable MCP tools pack (game can stay open).
# .\dist\build-mcp-tools.ps1
param(
[string]$Configuration = "Release",
[string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader"
)
& (Join-Path $PSScriptRoot "build-local.ps1") -Configuration $Configuration -GameDir $GameDir -ToolsOnly

View file

@ -0,0 +1,241 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using HarmonyLib;
using S3.Modules.Profiler;
using UnityEngine;
namespace S3.Mcp.Tools;
/// <summary>
/// Temporary, capture-scoped timings around game MonoBehaviour lifecycle methods.
/// This deliberately lives in the reloadable MCP tools pack so profiling coverage
/// can be refined without restarting the game.
/// </summary>
internal static class DeepMethodProfiler
{
static readonly string[] LifecycleMethods =
{
"FixedUpdate",
"Update",
"LateUpdate",
"OnPreCull",
"OnPreRender",
"OnPostRender",
"OnWillRenderObject",
};
static readonly string[] ExcludedAssemblyPrefixes =
{
"System",
"Microsoft",
"Mono.",
"mscorlib",
"netstandard",
"0Harmony",
"Newtonsoft.",
"UnityModManager",
"S3",
};
static readonly Dictionary<MethodBase, string> ProbeIds = new();
static Harmony? _harmony;
public static bool Active => _harmony != null;
public static int PatchedMethods => ProbeIds.Count;
public static bool Start(out string details)
{
if (_harmony != null)
{
details = $"already active ({ProbeIds.Count} methods)";
return true;
}
string harmonyId =
"S3.mcp.deep." + typeof(DeepMethodProfiler).Assembly.GetName().Name;
var harmony = new Harmony(harmonyId);
var prefix = new HarmonyMethod(
typeof(DeepMethodProfiler), nameof(Prefix));
var postfix = new HarmonyMethod(
typeof(DeepMethodProfiler), nameof(Postfix));
int patched = 0;
int lifecyclePatched = 0;
int stateMachinePatched = 0;
int failed = 0;
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (!IsCandidateAssembly(assembly)) continue;
foreach (Type type in GetTypesSafely(assembly))
{
if (type == null || type.IsAbstract) continue;
if (typeof(MonoBehaviour).IsAssignableFrom(type))
{
for (int i = 0; i < LifecycleMethods.Length; i++)
{
MethodInfo? method = FindParameterlessMethod(
type, LifecycleMethods[i]);
if (!CanPatch(method)) continue;
if (TryPatch(
harmony, method!, prefix, postfix,
$"deep.{method!.Name}:{DisplayTypeName(type)}"))
{
patched++;
lifecyclePatched++;
}
else failed++;
}
}
bool coroutine = typeof(IEnumerator).IsAssignableFrom(type);
bool asyncStateMachine =
typeof(IAsyncStateMachine).IsAssignableFrom(type);
if (coroutine || asyncStateMachine)
{
MethodInfo? moveNext = FindParameterlessMethod(type, "MoveNext");
if (!CanPatch(moveNext)) continue;
string kind = coroutine ? "Coroutine" : "Async";
if (TryPatch(
harmony, moveNext!, prefix, postfix,
$"deep.{kind}:{DisplayTypeName(type)}"))
{
patched++;
stateMachinePatched++;
}
else failed++;
}
}
}
if (patched == 0)
{
harmony.UnpatchAll(harmonyId);
ProbeIds.Clear();
details = $"no methods patched (failures={failed})";
return false;
}
_harmony = harmony;
details =
$"patched={patched} lifecycle={lifecyclePatched} " +
$"stateMachines={stateMachinePatched} failures={failed}";
return true;
}
public static void Stop()
{
Harmony? harmony = _harmony;
_harmony = null;
if (harmony != null)
{
try { harmony.UnpatchAll(harmony.Id); }
catch { }
}
ProbeIds.Clear();
}
static bool IsCandidateAssembly(Assembly assembly)
{
string name;
try { name = assembly.GetName().Name ?? ""; }
catch { return false; }
if (name.Equals("S3", StringComparison.OrdinalIgnoreCase))
return true;
for (int i = 0; i < ExcludedAssemblyPrefixes.Length; i++)
{
if (name.StartsWith(
ExcludedAssemblyPrefixes[i],
StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
static Type[] GetTypesSafely(Assembly assembly)
{
try { return assembly.GetTypes(); }
catch (ReflectionTypeLoadException ex)
{
var result = new List<Type>();
Type?[] types = ex.Types;
for (int i = 0; i < types.Length; i++)
if (types[i] != null) result.Add(types[i]!);
return result.ToArray();
}
catch { return Array.Empty<Type>(); }
}
static MethodInfo? FindParameterlessMethod(Type type, string name)
{
MethodInfo[] methods;
try
{
methods = type.GetMethods(
BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.NonPublic |
BindingFlags.DeclaredOnly);
}
catch { return null; }
for (int i = 0; i < methods.Length; i++)
if (methods[i].Name == name &&
methods[i].GetParameters().Length == 0)
return methods[i];
return null;
}
static bool CanPatch(MethodInfo? method)
{
if (method == null || method.IsAbstract ||
method.ContainsGenericParameters)
return false;
try { return method.GetMethodBody() != null; }
catch { return false; }
}
static bool TryPatch(
Harmony harmony,
MethodInfo method,
HarmonyMethod prefix,
HarmonyMethod postfix,
string id)
{
try
{
ProbeIds[method] = id;
harmony.Patch(method, prefix, postfix);
return true;
}
catch
{
ProbeIds.Remove(method);
return false;
}
}
static string DisplayTypeName(Type type)
{
string name = type.FullName ?? type.Name;
Type? declaring = type.DeclaringType;
if (declaring == null) return name;
return (declaring.FullName ?? declaring.Name) + "." + type.Name;
}
static void Prefix(out long __state)
{
__state = Stopwatch.GetTimestamp();
}
static void Postfix(MethodBase __originalMethod, long __state)
{
if (__state == 0 || !SparseHitchSampler.Active) return;
if (!ProbeIds.TryGetValue(__originalMethod, out string? id)) return;
SparseHitchSampler.Record(id, Stopwatch.GetTimestamp() - __state);
}
}

View file

@ -0,0 +1,116 @@
using System;
using System.Diagnostics;
using S3.Modules.Profiler;
using UnityEngine.LowLevel;
namespace S3.Mcp.Tools;
/// <summary>
/// Temporarily inserts timestamp boundaries around every Unity PlayerLoop
/// subsystem. This measures native engine phases that Harmony cannot patch.
/// </summary>
internal static class PlayerLoopProfiler
{
static PlayerLoopSystem _original;
static long[] _depthStarts = Array.Empty<long>();
public static bool Active { get; private set; }
public static int Boundaries { get; private set; }
public static string Start()
{
Stop();
try
{
_original = PlayerLoop.GetCurrentPlayerLoop();
PlayerLoopSystem instrumented = _original;
Boundaries = 0;
int maxDepth = MeasureDepth(instrumented, 0);
_depthStarts = new long[Math.Max(2, maxDepth + 2)];
Instrument(ref instrumented, 0, "PlayerLoop");
PlayerLoop.SetPlayerLoop(instrumented);
Active = true;
return $"boundaries={Boundaries} depth={maxDepth}";
}
catch (Exception ex)
{
Stop();
return "failed:" + ex.GetType().Name;
}
}
public static void Stop()
{
if (Active)
{
try { PlayerLoop.SetPlayerLoop(_original); }
catch { }
}
Active = false;
Boundaries = 0;
_depthStarts = Array.Empty<long>();
}
static int MeasureDepth(PlayerLoopSystem system, int depth)
{
int max = depth;
PlayerLoopSystem[]? children = system.subSystemList;
if (children == null) return max;
for (int i = 0; i < children.Length; i++)
max = Math.Max(max, MeasureDepth(children[i], depth + 1));
return max;
}
static void Instrument(
ref PlayerLoopSystem system,
int depth,
string parentPath)
{
PlayerLoopSystem[]? children = system.subSystemList;
if (children == null || children.Length == 0) return;
var expanded = new PlayerLoopSystem[children.Length * 2 + 1];
expanded[0] = Boundary(depth, null);
for (int i = 0; i < children.Length; i++)
{
PlayerLoopSystem child = children[i];
string childName = DisplayName(child.type, i);
string path = parentPath + "." + childName;
Instrument(ref child, depth + 1, path);
expanded[i * 2 + 1] = child;
expanded[i * 2 + 2] = Boundary(depth, "loop." + path);
Boundaries++;
}
system.subSystemList = expanded;
}
static PlayerLoopSystem Boundary(int depth, string? completedId)
{
return new PlayerLoopSystem
{
type = typeof(PlayerLoopProfiler),
updateDelegate = () => Mark(depth, completedId),
};
}
static void Mark(int depth, string? completedId)
{
long now = Stopwatch.GetTimestamp();
if (completedId != null &&
SparseHitchSampler.Active &&
depth < _depthStarts.Length)
{
long start = _depthStarts[depth];
if (start != 0 && now >= start)
SparseHitchSampler.Record(completedId, now - start);
}
if (depth < _depthStarts.Length)
_depthStarts[depth] = now;
}
static string DisplayName(Type? type, int index)
{
if (type == null) return "unnamed" + index;
return type.FullName ?? type.Name;
}
}

View file

@ -0,0 +1,60 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>annotations</Nullable>
<!-- Unique assembly identity every build so Unity Mono can Assembly.Load
a new copy without colliding with the previous one. File is copied
to the stable name S3.Mcp.Tools.dll; the host loads bytes. -->
<ToolsStamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))</ToolsStamp>
<AssemblyName>S3.Mcp.Tools_$(ToolsStamp)</AssemblyName>
<RootNamespace>S3.Mcp.Tools</RootNamespace>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<S3Dll>$(MSBuildThisFileDirectory)..\src\bin\$(Configuration)\S3.dll</S3Dll>
</PropertyGroup>
<ItemGroup>
<Reference Include="S3">
<HintPath>$(S3Dll)</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>$(GameManaged)\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(GameManaged)\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>$(GameManaged)\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(GameManaged)\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.PhysicsModule">
<HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.InputLegacyModule">
<HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(UmmDir)\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Target Name="CopyStablePlugin" AfterTargets="Build">
<MakeDir Directories="$(GameDir)\Mods\S3\plugins" />
<Copy SourceFiles="$(TargetPath)" DestinationFiles="$(GameDir)\Mods\S3\plugins\S3.Mcp.Tools.dll" />
<Message Importance="high" Text="Copied S3.Mcp.Tools.dll (assembly $(AssemblyName))" />
</Target>
</Project>

View file

@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using S3.Modules.Profiler;
using UnityEngine;
namespace S3.Mcp.Tools;
/// <summary>
/// Low-allocation manual sampler. It stores full probe detail only for hitch
/// frames and ignores sub-0.1ms samples, avoiding profiler-induced GC stalls.
/// </summary>
internal static class SparseHitchSampler
{
static readonly Dictionary<string, HitchProbeSample> Current =
new(StringComparer.Ordinal);
static readonly List<HitchFrameRecord> Frames = new(4096);
static GameObject? _driverObject;
static float _threshold;
static int _frame;
static int _gc0;
static int _gc1;
static int _gc2;
static long _mono;
static bool _pending;
public static bool Active { get; private set; }
public static void Begin(float thresholdMs)
{
Cancel();
Current.Clear();
Frames.Clear();
_threshold = thresholdMs;
_frame = 0;
_gc0 = GC.CollectionCount(0);
_gc1 = GC.CollectionCount(1);
_gc2 = GC.CollectionCount(2);
_mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
_pending = false;
Active = true;
_driverObject = new GameObject("S3 Sparse Hitch Sampler");
_driverObject.hideFlags = HideFlags.HideAndDontSave;
UnityEngine.Object.DontDestroyOnLoad(_driverObject);
_driverObject.AddComponent<SparseHitchFrameDriver>();
}
public static List<HitchFrameRecord> End()
{
Active = false;
DestroyDriver();
Current.Clear();
_pending = false;
return new List<HitchFrameRecord>(Frames);
}
public static void Cancel()
{
Active = false;
DestroyDriver();
Current.Clear();
Frames.Clear();
_pending = false;
}
public static void Record(string id, long ticks, int calls = 1)
{
if (!Active || ticks <= Stopwatch.Frequency / 10_000) return;
if (!Current.TryGetValue(id, out HitchProbeSample? sample))
{
sample = new HitchProbeSample();
Current[id] = sample;
}
sample.Calls += calls;
sample.Ticks += ticks;
if (ticks > sample.MaxTicks) sample.MaxTicks = ticks;
}
public static void Advance(float frameMs)
{
if (!Active) return;
int gc0 = GC.CollectionCount(0);
int gc1 = GC.CollectionCount(1);
int gc2 = GC.CollectionCount(2);
long mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
if (_pending && frameMs > 0f && frameMs < 2000f)
{
var frame = new HitchFrameRecord
{
Phase = "manual-sparse",
Frame = _frame++,
FrameMs = frameMs,
Gc0 = gc0 - _gc0,
Gc1 = gc1 - _gc1,
Gc2 = gc2 - _gc2,
MonoDelta = mono - _mono,
CameraPosition = Camera.main != null
? Camera.main.transform.position
: Vector3.zero,
};
if (frameMs >= _threshold)
{
foreach (var pair in Current)
{
HitchProbeSample sample = pair.Value;
if (sample.Calls == 0) continue;
frame.Probes[pair.Key] = new HitchProbeSample
{
Calls = sample.Calls,
Ticks = sample.Ticks,
MaxTicks = sample.MaxTicks,
};
}
}
Frames.Add(frame);
}
foreach (HitchProbeSample sample in Current.Values)
{
sample.Calls = 0;
sample.Ticks = 0;
sample.MaxTicks = 0;
}
_gc0 = gc0;
_gc1 = gc1;
_gc2 = gc2;
_mono = mono;
_pending = true;
}
static void DestroyDriver()
{
if (_driverObject == null) return;
UnityEngine.Object.Destroy(_driverObject);
_driverObject = null;
}
}
[DefaultExecutionOrder(-31999)]
public sealed class SparseHitchFrameDriver : MonoBehaviour
{
void Update() =>
SparseHitchSampler.Advance(Time.unscaledDeltaTime * 1000f);
}

1029
mcp-tools/ToolsPlugin.cs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using S3.Modules.Profiler;
using Unity.Profiling;
using Unity.Profiling.LowLevel.Unsafe;
using UnityEngine;
namespace S3.Mcp.Tools;
/// <summary>Capture-scoped Unity PlayerLoop and subsystem marker recorders.</summary>
internal static class UnityMarkerProfiler
{
sealed class Entry
{
public string Id = "";
public ProfilerRecorder Recorder;
}
readonly struct Marker
{
public readonly ProfilerCategory Category;
public readonly string Name;
public Marker(ProfilerCategory category, string name)
{
Category = category;
Name = name;
}
}
static readonly Marker[] CandidateMarkers =
{
new(ProfilerCategory.Scripts, "BehaviourUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunBehaviourUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunBehaviourFixedUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunBehaviourLateUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunDelayedTasks"),
new(ProfilerCategory.Scripts, "CoroutinesDelayedCalls"),
new(ProfilerCategory.Scripts, "GC.Collect"),
new(ProfilerCategory.Scripts, "GC.Incremental.Collect"),
new(ProfilerCategory.Internal, "PlayerLoop"),
new(ProfilerCategory.Internal, "EarlyUpdate"),
new(ProfilerCategory.Internal, "FixedUpdate"),
new(ProfilerCategory.Internal, "PreUpdate"),
new(ProfilerCategory.Internal, "Update"),
new(ProfilerCategory.Internal, "PreLateUpdate"),
new(ProfilerCategory.Internal, "PostLateUpdate"),
new(ProfilerCategory.Internal, "WaitForJobGroupID"),
new(ProfilerCategory.Internal, "JobHandle.Complete"),
new(ProfilerCategory.Internal, "Semaphore.WaitForSignal"),
new(ProfilerCategory.Internal, "WaitForTargetFPS"),
new(ProfilerCategory.Internal, "Gfx.WaitForPresentOnGfxThread"),
new(ProfilerCategory.Internal, "Gfx.PresentFrame"),
new(ProfilerCategory.Loading, "PreloadManager.UpdatePreloading"),
new(ProfilerCategory.Loading, "Application.Integrate Assets in Background"),
new(ProfilerCategory.Loading, "AsyncUploadManager.Update"),
new(ProfilerCategory.Loading, "Resources.UnloadUnusedAssets"),
new(ProfilerCategory.Loading, "SceneManager.Update"),
new(ProfilerCategory.Render, "UpdateAllRenderers"),
new(ProfilerCategory.Render, "UpdateRendererBoundingVolumes"),
new(ProfilerCategory.Render, "UpdateAllSkinnedMeshes"),
new(ProfilerCategory.Render, "Camera.Render"),
new(ProfilerCategory.Render, "CullScriptable"),
new(ProfilerCategory.Render, "RenderLoop.Draw"),
new(ProfilerCategory.Render, "BatchRendererGroup"),
new(ProfilerCategory.Animation, "DirectorUpdateAnimationBegin"),
new(ProfilerCategory.Animation, "DirectorUpdateAnimationEnd"),
new(ProfilerCategory.Animation, "Animator.Update"),
new(ProfilerCategory.Physics, "Physics.Simulate"),
new(ProfilerCategory.Particles, "ParticleSystem.Update"),
new(ProfilerCategory.Particles, "ParticleSystem.ScheduleGeometryJobs"),
};
static readonly List<Entry> Entries = new();
static GameObject? _driverObject;
public static int ActiveMarkers => Entries.Count;
public static string Start()
{
Stop();
var names = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var available = new List<ProfilerRecorderHandle>();
try { ProfilerRecorderHandle.GetAvailable(available); }
catch { }
for (int i = 0; i < available.Count && Entries.Count < 512; i++)
{
try
{
ProfilerRecorderDescription description =
ProfilerRecorderHandle.GetDescription(available[i]);
string name = description.Name;
if (description.UnitType !=
ProfilerMarkerDataUnit.TimeNanoseconds)
continue;
AddRecorder(description.Category, name, names, seen);
}
catch { }
}
// Some release players expose only a small built-in counter list.
// Probe known marker/category pairs to supplement that list.
for (int i = 0;
i < CandidateMarkers.Length && Entries.Count < 512;
i++)
{
Marker marker = CandidateMarkers[i];
AddRecorder(marker.Category, marker.Name, names, seen);
}
if (Entries.Count > 0)
{
_driverObject = new GameObject("S3 Deep Unity Marker Profiler");
_driverObject.hideFlags = HideFlags.HideAndDontSave;
UnityEngine.Object.DontDestroyOnLoad(_driverObject);
_driverObject.AddComponent<UnityMarkerFrameDriver>();
}
return Entries.Count == 0
? "markers=0"
: $"available={available.Count} markers={Entries.Count} " +
$"[{string.Join(", ", names.GetRange(0, Math.Min(20, names.Count)))}" +
(names.Count > 20 ? ", ...]" : "]");
}
static void AddRecorder(
ProfilerCategory category,
string name,
List<string> names,
HashSet<string> seen)
{
string unique = category.Name + ":" + name;
if (!seen.Add(unique)) return;
try
{
ProfilerRecorder recorder = ProfilerRecorder.StartNew(
category,
name,
1,
ProfilerRecorderOptions.StartImmediately |
ProfilerRecorderOptions.SumAllSamplesInFrame);
if (!recorder.Valid)
{
recorder.Dispose();
return;
}
string id = "unity." + category.Name + ":" + name;
Entries.Add(new Entry { Id = id, Recorder = recorder });
names.Add(unique);
}
catch { }
}
public static void Stop()
{
if (_driverObject != null)
{
UnityEngine.Object.Destroy(_driverObject);
_driverObject = null;
}
for (int i = 0; i < Entries.Count; i++)
{
try { Entries[i].Recorder.Dispose(); }
catch { }
}
Entries.Clear();
}
public static void Sample()
{
if (!SparseHitchSampler.Active) return;
for (int i = 0; i < Entries.Count; i++)
{
Entry entry = Entries[i];
long nanoseconds;
try { nanoseconds = entry.Recorder.LastValue; }
catch { continue; }
if (nanoseconds <= 0 || nanoseconds > 10_000_000_000L)
continue;
long ticks = (long)(
nanoseconds * (double)Stopwatch.Frequency / 1_000_000_000.0);
SparseHitchSampler.Record(entry.Id, ticks);
}
}
}
[DefaultExecutionOrder(31900)]
public sealed class UnityMarkerFrameDriver : MonoBehaviour
{
void LateUpdate() => UnityMarkerProfiler.Sample();
}

View file

@ -39,6 +39,7 @@ public static class Main
_registry.Register(new Modules.QuickActions.QuickActionsModule());
_registry.Register(new Modules.CarCards.CarCardsModule());
_registry.Register(new Modules.IndustryTags.IndustryTagsModule());
_registry.Register(new Modules.Mcp.McpModule());
_registry.EnableConfigured();
ModConflicts.CheckAtLoad();

View file

@ -0,0 +1,51 @@
using System;
using HarmonyLib;
using S3.Core;
using UI.Console;
namespace S3.Modules.Mcp;
[HarmonyPatch(typeof(ConsoleCommandHandler))]
[HarmonyPatch("_HandleSlashCommand")]
static class McpConsolePatch
{
static bool Prefix(string[] comps, ref string __result)
{
if (comps.Length == 0 || !string.Equals(comps[0], "/s3mcp", StringComparison.OrdinalIgnoreCase))
return true;
__result = McpConsole.Handle(comps);
return false;
}
}
static class McpConsole
{
internal static string Handle(string[] comps)
{
string sub = comps.Length >= 2 ? comps[1].ToLowerInvariant() : "status";
var host = McpHost.Instance;
var s = McpModule.Settings;
switch (sub)
{
case "help":
return "Usage: /s3mcp [status|token|reload|restart|help]";
case "token":
return s.token;
case "reload":
if (host == null) return "MCP host not running (enable the module and restart).";
return host.ReloadNow(restartServer: true);
case "restart":
if (host == null) return "MCP host not running.";
host.RequestRestart();
return "restart queued";
default:
if (host == null)
return "MCP host not running (enable MCP in S3 settings and restart).";
return host.ListenSummary + "\n" + host.Url + "\ntoken=" + s.token +
"\nplugins=" + host.PluginSummary +
"\nobserve=" + s.observe + " control=" + s.control + " develop=" + s.develop +
"\nautoReload=" + s.autoReload;
}
}
}

286
src/Modules/Mcp/McpGame.cs Normal file
View file

@ -0,0 +1,286 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Game;
using HarmonyLib;
using Helpers;
using Model;
using Model.Physics;
using S3.Core;
using S3.Mcp;
using S3.Modules.PhysicsOptimizer;
using UI.Console;
using UI.Menu;
using UnityEngine;
namespace S3.Modules.Mcp;
sealed class McpGame : IMcpGame
{
static PersistentLoader? _loader;
static GameObject? _loadingScreen;
public bool InPlay => ComputeInPlay();
public string StatusText()
{
var s = McpModule.Settings;
var sb = new StringBuilder();
sb.AppendLine("s3-mcp");
sb.AppendLine($"inPlay={InPlay}");
sb.AppendLine($"observe={s.observe} control={s.control} develop={s.develop}");
sb.AppendLine($"autoReload={s.autoReload}");
var host = McpHost.Instance;
if (host != null)
{
sb.AppendLine($"listen={host.ListenSummary}");
sb.AppendLine($"url={host.Url}");
sb.AppendLine($"plugins={host.PluginSummary}");
}
try
{
var car = TrainController.Shared?.SelectedCar;
if (car != null)
sb.AppendLine($"selected={car.DisplayName} id={car.id}");
else
sb.AppendLine("selected=(none)");
}
catch { sb.AppendLine("selected=(error)"); }
Camera? cam = Camera.main;
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
catch { }
if (cam != null)
{
Vector3 p = cam.transform.position;
sb.AppendLine($"cam={cam.name} pos=({p.x:0.0},{p.y:0.0},{p.z:0.0}) fov={cam.fieldOfView:0.0}");
}
else sb.AppendLine("cam=(none)");
return sb.ToString().TrimEnd();
}
public string ModulesText()
{
var sb = new StringBuilder();
var reg = Main.Registry;
if (reg == null)
return "no registry";
foreach (IModule m in reg.Modules)
sb.AppendLine(
$"{m.Id,-16} active={reg.IsActive(m),-5} configured={m.Enabled,-5} {m.DisplayName}");
return sb.ToString().TrimEnd();
}
public string LogTail(int lines) => string.Join("\n", Log.Tail(lines));
public string ConsistsText(int limit)
{
if (limit < 1) limit = 1;
if (limit > 200) limit = 200;
var tc = TrainController.Shared;
if (tc == null) return "not in play";
Car? selected = null;
try { selected = tc.SelectedCar; }
catch { }
var seenSets = new HashSet<uint>();
var seenLoose = new HashSet<string>();
var sb = new StringBuilder();
int n = 0;
try
{
foreach (Car car in tc.Cars)
{
if (car == null) continue;
IntegrationSet? set = car.set;
if (set != null)
{
if (!seenSets.Add(set.Id)) continue;
bool sel = false;
try { sel = selected != null && selected.set != null && selected.set.Id == set.Id; }
catch { }
string lead = car.DisplayName;
sb.AppendLine($"set #{set.Id} lead={lead} cars={set.NumberOfCars}{(sel ? " SELECTED" : "")}");
}
else
{
if (!seenLoose.Add(car.id)) continue;
bool sel = selected != null && selected.id == car.id;
sb.AppendLine($"{car.id} {car.DisplayName}{(sel ? " SELECTED" : "")}");
}
n++;
if (n >= limit) break;
}
}
catch (Exception e)
{
sb.AppendLine("error: " + e.Message);
}
sb.AppendLine($"listed={n}");
return sb.ToString().TrimEnd();
}
public string SelectCar(string id)
{
var tc = TrainController.Shared;
if (tc == null) return "not in play";
if (string.IsNullOrEmpty(id)) return "id required";
try
{
if (!tc.TryGetCarForId(id, out Car car) || car == null)
return $"no car {id}";
tc.SelectedCar = car;
return $"selected {car.DisplayName} ({car.id})";
}
catch (Exception e)
{
return "select failed: " + e.Message;
}
}
public string FreezeSelected(bool freeze)
{
Car? car = null;
try { car = TrainController.Shared?.SelectedCar; }
catch { }
if (car == null) return "no car selected";
IntegrationSet? set = car.set;
if (set == null) return $"{car.id} has no IntegrationSet";
if (freeze)
{
set.SetVelocity(0f, set.Cars.ToList());
bool added = ConsistFreezer.Freeze(set.Id);
return added
? $"froze set #{set.Id} ({set.NumberOfCars} cars)"
: $"set #{set.Id} already frozen";
}
bool removed = ConsistFreezer.Unfreeze(set.Id);
return removed
? $"unfroze set #{set.Id}"
: $"set #{set.Id} was not frozen";
}
public string RunSlash(string command)
{
if (string.IsNullOrWhiteSpace(command))
return "empty command";
command = command.Trim();
if (!command.StartsWith("/"))
command = "/" + command;
var handler = UnityEngine.Object.FindObjectOfType<ConsoleCommandHandler>();
if (handler == null)
return "ConsoleCommandHandler not found (open a session first).";
string[] comps;
try
{
var tokenize = typeof(ConsoleCommandHandler).GetMethod(
"Tokenize", BindingFlags.NonPublic | BindingFlags.Static);
if (tokenize == null)
comps = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
else
{
var list = tokenize.Invoke(null, new object[] { command }) as List<string>;
comps = list != null ? list.ToArray() : Array.Empty<string>();
}
}
catch
{
comps = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
}
if (comps.Length == 0)
return "empty command";
try
{
string? text = Traverse.Create(handler)
.Method("_HandleSlashCommand", new[] { typeof(string[]) }, new object[] { comps })
.GetValue<string>();
return string.IsNullOrEmpty(text) ? "(no output)" : text;
}
catch (Exception e)
{
return "slash failed: " + e.Message;
}
}
public string Screenshot(int maxWidth)
{
if (maxWidth < 160) maxWidth = 160;
if (maxWidth > 2560) maxWidth = 2560;
Camera? cam = Camera.main;
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
catch { }
if (cam == null)
return "no camera";
int w = Math.Max(160, cam.pixelWidth);
int h = Math.Max(90, cam.pixelHeight);
if (w > maxWidth)
{
float scale = maxWidth / (float)w;
w = maxWidth;
h = Math.Max(90, (int)(h * scale));
}
RenderTexture? old = cam.targetTexture;
var rt = RenderTexture.GetTemporary(w, h, 24);
Texture2D? tex = null;
try
{
cam.targetTexture = rt;
cam.Render();
RenderTexture.active = rt;
tex = new Texture2D(w, h, TextureFormat.RGB24, false);
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
tex.Apply();
byte[] png = tex.EncodeToPNG();
string path = Path.Combine(Main.ModEntry.Path, "mcp-shot.png");
File.WriteAllBytes(path, png);
return path;
}
catch (Exception e)
{
return "screenshot failed: " + e.Message;
}
finally
{
cam.targetTexture = old;
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(rt);
if (tex != null) UnityEngine.Object.Destroy(tex);
}
}
static bool ComputeInPlay()
{
try
{
if (TrainController.Shared == null) return false;
if (SceneDescriptor.MainMenu.IsLoaded) return false;
if (!SceneDescriptor.GameUI.IsLoaded) return false;
return !LoadingScreenVisible();
}
catch { return false; }
}
static bool LoadingScreenVisible()
{
try
{
if (_loadingScreen != null)
return _loadingScreen.activeInHierarchy;
if (_loader == null)
_loader = UnityEngine.Object.FindObjectOfType<PersistentLoader>();
if (_loader == null) return false;
_loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue<GameObject>();
return _loadingScreen != null && _loadingScreen.activeInHierarchy;
}
catch { return false; }
}
}

362
src/Modules/Mcp/McpHost.cs Normal file
View file

@ -0,0 +1,362 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using S3.Core;
using S3.Mcp;
using UnityEngine;
namespace S3.Modules.Mcp;
sealed class McpWork
{
public JObject Request = null!;
public JObject? Response;
public ManualResetEventSlim Done = new(false);
}
sealed class McpHost : MonoBehaviour
{
public static McpHost? Instance { get; private set; }
readonly ConcurrentQueue<McpWork> _queue = new();
readonly McpRegistry _registry = new();
readonly McpGame _game = new();
McpPlugins _plugins = null!;
McpServer? _server;
FileSystemWatcher? _watch;
int _watchPulse;
int _suppressWatchUntil;
bool _reloadQueued;
bool _restartQueued;
string _lastError = "";
public string ListenSummary
{
get
{
if (_server == null || !_server.Running)
return "down" + (string.IsNullOrEmpty(_lastError) ? "" : " (" + _lastError + ")");
return "127.0.0.1:" + _server.BoundPort;
}
}
public string Url =>
_server != null && _server.Running
? "http://127.0.0.1:" + _server.BoundPort + "/mcp"
: "";
public string PluginSummary => _plugins != null ? _plugins.Summary : "(init)";
public string LastError => _lastError;
void Awake()
{
Instance = this;
_plugins = new McpPlugins(_registry, _game);
RegisterHostTools();
StartServer();
ReloadTools(restartServer: false);
StartWatcher();
}
void OnDestroy()
{
if (Instance == this) Instance = null;
StopWatcher();
_plugins?.StopAll();
_server?.Stop();
while (_queue.TryDequeue(out var work))
{
work.Response = ProtocolError(work.Request, "host destroyed");
work.Done.Set();
}
}
void Update()
{
DrainQueue();
MaybeWatchReload();
if (_reloadQueued)
{
_reloadQueued = false;
bool restart = _restartQueued;
_restartQueued = false;
ReloadTools(restart);
}
else if (_restartQueued)
{
_restartQueued = false;
StartServer();
}
}
void MaybeWatchReload()
{
if (_watchPulse == 0) return;
int now = Environment.TickCount;
if (now - _watchPulse < 800) return;
_watchPulse = 0;
if (!McpModule.Settings.autoReload) return;
RequestReload(restartServer: true);
}
public void RequestReload(bool restartServer)
{
_reloadQueued = true;
if (restartServer) _restartQueued = true;
}
public void RequestRestart() => _restartQueued = true;
public string ReloadNow(bool restartServer)
{
return ReloadTools(restartServer);
}
JObject? DispatchOnMain(JObject request)
{
var work = new McpWork { Request = request };
_queue.Enqueue(work);
int timeout = Math.Max(1000, McpModule.Settings.callTimeoutMs);
if (!work.Done.Wait(timeout))
return ProtocolError(request, "timed out waiting for Unity main thread (" + timeout + "ms)");
return work.Response;
}
void DrainQueue()
{
int n = 0;
while (n < 8 && _queue.TryDequeue(out var work))
{
n++;
try { work.Response = McpProtocol.Handle(work.Request, _registry); }
catch (Exception e)
{
Log.Error("[mcp] dispatch: " + e);
work.Response = ProtocolError(work.Request, e.Message);
}
work.Done.Set();
}
}
void StartServer()
{
_server?.Stop();
var s = McpModule.Settings;
_server = new McpServer(s.token ?? "", DispatchOnMain);
if (!_server.Start(s.port))
{
_lastError = _server.LastError;
return;
}
_lastError = "";
WriteConnectFile();
}
string ReloadTools(bool restartServer)
{
_suppressWatchUntil = Environment.TickCount + 1500;
_watchPulse = 0;
var sb = new System.Text.StringBuilder();
sb.AppendLine(_plugins.ReloadOfficial());
if (McpModule.Settings.develop)
sb.AppendLine(_plugins.ReloadExtras());
if (restartServer)
StartServer();
string text = sb.ToString().TrimEnd();
Log.Info("[mcp] reload:\n" + text);
return text;
}
void StartWatcher()
{
StopWatcher();
try
{
Directory.CreateDirectory(_plugins.PluginDir);
_watch = new FileSystemWatcher(_plugins.PluginDir);
_watch.Filter = "*.dll";
_watch.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | NotifyFilters.CreationTime;
_watch.Changed += OnWatch;
_watch.Created += OnWatch;
_watch.Renamed += OnWatch;
_watch.EnableRaisingEvents = true;
}
catch (Exception e)
{
Log.Warn("[mcp] watcher: " + e.Message);
}
}
void StopWatcher()
{
if (_watch == null) return;
try
{
_watch.EnableRaisingEvents = false;
_watch.Changed -= OnWatch;
_watch.Created -= OnWatch;
_watch.Renamed -= OnWatch;
_watch.Dispose();
}
catch { }
_watch = null;
}
void OnWatch(object sender, FileSystemEventArgs e)
{
if (!McpModule.Settings.autoReload) return;
int now = Environment.TickCount;
if (now - _suppressWatchUntil < 0) return;
string name = Path.GetFileName(e.Name ?? e.FullPath);
bool official = string.Equals(name, McpPlugins.OfficialFileName, StringComparison.OrdinalIgnoreCase);
bool extra = McpModule.Settings.develop && name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase);
if (!official && !extra) return;
_watchPulse = now;
}
void WriteConnectFile()
{
try
{
var obj = new JObject
{
["url"] = Url,
["token"] = McpModule.Settings.token,
["port"] = _server?.BoundPort ?? McpModule.Settings.port,
["protocol"] = "mcp-streamable-http",
};
File.WriteAllText(
Path.Combine(Main.ModEntry.Path, "mcp-connect.json"),
obj.ToString(Formatting.Indented));
}
catch (Exception e)
{
Log.Warn("[mcp] connect file: " + e.Message);
}
}
void RegisterHostTools()
{
AddHost("status",
"Railroader S3 MCP host status: in-play, selected car, gates, listen URL, loaded plugins.",
"{}",
_ => McpToolResult.Ok(_game.StatusText()));
AddHost("mcp_reload",
"Reload S3.Mcp.Tools.dll (and extra plugins if Develop is on). Restarts the HTTP listener by default so Cursor re-fetches tools. Use this after dist/build-mcp-tools.ps1.",
"{\"restart\":{\"type\":\"boolean\",\"description\":\"Bounce the HTTP server (default true).\"}}",
args =>
{
bool restart = args["restart"] == null || args["restart"]!.Value<bool?>() != false;
if (restart)
{
RequestReload(restartServer: true);
return McpToolResult.Ok("reload+restart queued (listener will bounce after this call)");
}
return McpToolResult.Ok(ReloadNow(false));
});
AddHost("mcp_restart",
"Restart the localhost MCP HTTP listener without reloading plugin DLLs.",
"{}",
_ =>
{
RequestRestart();
return McpToolResult.Ok("restart queued");
});
AddHost("plugin_list",
"List loaded MCP plugins and DLLs in Mods/S3/plugins.",
"{}",
_ =>
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("loaded: " + _plugins.Summary);
try
{
Directory.CreateDirectory(_plugins.PluginDir);
foreach (string f in Directory.GetFiles(_plugins.PluginDir, "*.dll"))
sb.AppendLine("file: " + Path.GetFileName(f));
}
catch (Exception e) { sb.AppendLine(e.Message); }
return McpToolResult.Ok(sb.ToString().TrimEnd());
});
AddHost("plugin_load",
"Load or reload a plugin DLL from Mods/S3/plugins. Develop gate required for names other than S3.Mcp.Tools.dll.",
"{\"name\":{\"type\":\"string\",\"description\":\"DLL file name. Omit to reload official tools.\"}}",
args =>
{
string name = args["name"]?.Value<string>() ?? "";
if (string.IsNullOrEmpty(name) ||
string.Equals(name, McpPlugins.OfficialFileName, StringComparison.OrdinalIgnoreCase))
return McpToolResult.Ok(ReloadNow(restartServer: false));
if (!McpModule.Settings.develop)
return McpToolResult.Fail("Develop gate is off");
return McpToolResult.Ok(_plugins.LoadNamed(name));
});
AddHost("plugin_stop",
"Stop a loaded plugin by id and drop its tools.",
"{\"id\":{\"type\":\"string\"}}",
args => McpToolResult.Ok(_plugins.Stop(args["id"]?.Value<string>() ?? "")));
AddHost("console",
"Run an in-game slash command on the Unity main thread (e.g. /s3ind dump, /s3wq dump, /rpf dump).",
"{\"command\":{\"type\":\"string\"}}",
args =>
{
string cmd = args["command"]?.Value<string>() ?? "";
return McpToolResult.Ok(_game.RunSlash(cmd));
});
AddHost("log_tail",
"Last lines of the S3 log ring buffer.",
"{\"lines\":{\"type\":\"integer\"}}",
args =>
{
int lines = args["lines"]?.Value<int?>() ?? 80;
return McpToolResult.Ok(_game.LogTail(lines));
});
AddHost("modules",
"S3 module id, enabled flag, and display name.",
"{}",
_ => McpToolResult.Ok(_game.ModulesText()));
}
void AddHost(string name, string description, string propsJson, Func<JObject, McpToolResult> handler)
{
JObject props;
try { props = JObject.Parse("{\"type\":\"object\",\"properties\":" + propsJson + ",\"additionalProperties\":false}"); }
catch { props = JObject.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}"); }
if (propsJson == "{}")
props = JObject.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}");
_registry.Add(new McpToolEntry
{
Name = name,
Description = description,
InputSchema = props,
Gate = McpGate.Observe,
Source = "host",
Handler = handler,
});
}
static JObject ProtocolError(JObject request, string message)
{
var obj = new JObject
{
["jsonrpc"] = "2.0",
["error"] = new JObject { ["code"] = -32603, ["message"] = message },
};
if (request["id"] != null && request["id"]!.Type != JTokenType.Null)
obj["id"] = request["id"];
return obj;
}
}

View file

@ -0,0 +1,66 @@
using System;
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.Mcp;
public sealed class McpModule : IModule
{
private const string SettingsFile = "S3.mcp.json";
public static McpSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
public McpModule()
{
Settings = SettingsStore.Load<McpSettings>(SettingsFile);
if (string.IsNullOrEmpty(Settings.token))
{
Settings.token = "s3-local-dev";
SettingsStore.Save(SettingsFile, Settings);
}
if (Settings.port <= 0 || Settings.port >= 65536)
Settings.port = 18765;
if (Settings.callTimeoutMs < 1000)
Settings.callTimeoutMs = 8000;
}
public string Id => "mcp";
public string DisplayName => "MCP";
public string Description =>
"Localhost MCP server so a coding agent can inspect the live game and, with gates on, " +
"control it or load plugin DLLs. Tools reload without quitting. Console: /s3mcp";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
_harmony = new Harmony("S3.mcp");
try { _harmony.CreateClassProcessor(typeof(McpConsolePatch)).Patch(); }
catch (Exception e) { Log.Error($"[mcp] console patch failed: {e.Message}"); }
_hostGo = new GameObject("[S3] McpHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
_hostGo.AddComponent<McpHost>();
}
public void OnDisable()
{
_harmony?.UnpatchAll("S3.mcp");
_harmony = null;
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => McpSettingsUI.Draw();
}

View file

@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Newtonsoft.Json.Linq;
using S3.Core;
using S3.Mcp;
namespace S3.Modules.Mcp;
sealed class LoadedPlugin
{
public string Id = "";
public string Path = "";
public IAgentPlugin Instance = null!;
}
sealed class McpPlugins
{
public const string OfficialFileName = "S3.Mcp.Tools.dll";
readonly McpRegistry _registry;
readonly IMcpGame _game;
readonly List<LoadedPlugin> _loaded = new();
public McpPlugins(McpRegistry registry, IMcpGame game)
{
_registry = registry;
_game = game;
}
public string PluginDir => Path.Combine(Main.ModEntry.Path, "plugins");
public string OfficialPath => Path.Combine(PluginDir, OfficialFileName);
public string Summary
{
get
{
if (_loaded.Count == 0) return "(none)";
var ids = new string[_loaded.Count];
for (int i = 0; i < _loaded.Count; i++)
ids[i] = _loaded[i].Id;
return string.Join(", ", ids);
}
}
public IReadOnlyList<LoadedPlugin> Loaded => _loaded;
public string ReloadOfficial()
{
UnloadById("s3.mcp.tools");
UnloadById("tools");
Directory.CreateDirectory(PluginDir);
if (!File.Exists(OfficialPath))
return "no " + OfficialFileName + " in " + PluginDir;
return LoadFile(OfficialPath);
}
public string ReloadExtras()
{
Directory.CreateDirectory(PluginDir);
var sb = new System.Text.StringBuilder();
foreach (string path in Directory.GetFiles(PluginDir, "*.dll"))
{
string name = Path.GetFileName(path);
if (string.Equals(name, OfficialFileName, StringComparison.OrdinalIgnoreCase))
continue;
if (name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase))
continue;
UnloadByFile(path);
sb.AppendLine(LoadFile(path));
}
string text = sb.ToString().TrimEnd();
return string.IsNullOrEmpty(text) ? "no extra plugins" : text;
}
public string LoadNamed(string name)
{
Directory.CreateDirectory(PluginDir);
string path = name;
if (!Path.IsPathRooted(path))
path = Path.Combine(PluginDir, name);
if (!path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
path += ".dll";
if (!File.Exists(path))
return "not found: " + path;
UnloadByFile(path);
return LoadFile(path);
}
public string Stop(string id)
{
if (string.IsNullOrEmpty(id))
return "id required";
if (!UnloadById(id))
return "not loaded: " + id;
return "stopped " + id;
}
public void StopAll()
{
for (int i = _loaded.Count - 1; i >= 0; i--)
StopOne(_loaded[i]);
_loaded.Clear();
}
string LoadFile(string path)
{
byte[] bytes;
try { bytes = File.ReadAllBytes(path); }
catch (Exception e) { return "read failed " + path + ": " + e.Message; }
Assembly asm;
try { asm = Assembly.Load(bytes); }
catch (Exception e) { return "Assembly.Load failed " + Path.GetFileName(path) + ": " + e.Message; }
Type? pluginType = null;
try
{
foreach (Type t in asm.GetTypes())
{
if (t == null || t.IsAbstract || t.IsInterface) continue;
if (typeof(IAgentPlugin).IsAssignableFrom(t))
{
pluginType = t;
break;
}
}
}
catch (ReflectionTypeLoadException e)
{
return "types failed in " + Path.GetFileName(path) + ": " + (e.LoaderExceptions?.Length > 0 ? e.LoaderExceptions[0]?.Message : e.Message);
}
if (pluginType == null)
return Path.GetFileName(path) + " has no IAgentPlugin";
IAgentPlugin instance;
try { instance = (IAgentPlugin)Activator.CreateInstance(pluginType)!; }
catch (Exception e) { return "create failed: " + e.Message; }
string id = instance.Id;
if (string.IsNullOrEmpty(id))
id = pluginType.Name;
UnloadById(id);
var api = new McpApi(_registry, _game, id);
try { instance.Start(api); }
catch (Exception e)
{
try { instance.Stop(); }
catch { }
return "Start failed (" + id + "): " + e.Message;
}
_loaded.Add(new LoadedPlugin { Id = id, Path = path, Instance = instance });
Log.Info("[mcp] loaded plugin " + id + " from " + Path.GetFileName(path));
return "loaded " + id + " (" + Path.GetFileName(path) + ")";
}
bool UnloadById(string id)
{
bool any = false;
for (int i = _loaded.Count - 1; i >= 0; i--)
{
if (!string.Equals(_loaded[i].Id, id, StringComparison.OrdinalIgnoreCase))
continue;
StopOne(_loaded[i]);
_loaded.RemoveAt(i);
any = true;
}
return any;
}
void UnloadByFile(string path)
{
for (int i = _loaded.Count - 1; i >= 0; i--)
{
if (!string.Equals(_loaded[i].Path, path, StringComparison.OrdinalIgnoreCase))
continue;
StopOne(_loaded[i]);
_loaded.RemoveAt(i);
}
}
void StopOne(LoadedPlugin p)
{
_registry.ClearSource(p.Id);
try { p.Instance.Stop(); }
catch (Exception e) { Log.Warn("[mcp] Stop " + p.Id + ": " + e.Message); }
}
}
sealed class McpApi : IMcpApi
{
readonly McpRegistry _registry;
readonly string _sourceId;
readonly IMcpGame _game;
public McpApi(McpRegistry registry, IMcpGame game, string sourceId)
{
_registry = registry;
_game = game;
_sourceId = sourceId;
}
public bool Observe => McpModule.Settings.observe;
public bool Control => McpModule.Settings.control;
public bool Develop => McpModule.Settings.develop;
public IMcpGame Game => _game;
public void Log(string msg) => S3.Core.Log.Info("[mcp:" + _sourceId + "] " + msg);
public void RegisterTool(
string name,
string description,
string inputSchemaJson,
McpGate gate,
Func<JObject, McpToolResult> handler)
{
JObject schema;
try { schema = string.IsNullOrEmpty(inputSchemaJson) ? EmptySchema() : JObject.Parse(inputSchemaJson); }
catch { schema = EmptySchema(); }
_registry.Add(new McpToolEntry
{
Name = name,
Description = description ?? "",
InputSchema = schema,
Gate = gate,
Source = _sourceId,
Handler = handler,
});
}
static JObject EmptySchema() => new()
{
["type"] = "object",
["properties"] = new JObject(),
["additionalProperties"] = false,
};
}

View file

@ -0,0 +1,183 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
using S3.Core;
using S3.Mcp;
namespace S3.Modules.Mcp;
static class McpProtocol
{
public const string ServerName = "s3";
public static JObject? Handle(JObject req, McpRegistry registry)
{
string method = req["method"]?.Value<string>() ?? "";
JToken? id = req["id"];
bool notification = id == null || id.Type == JTokenType.Null;
JObject args = req["params"] as JObject ?? new JObject();
try
{
if (method == "initialize")
return Result(id, Initialize(args));
if (method == "notifications/initialized" || method == "initialized")
return notification ? null : Result(id, new JObject());
if (method == "ping")
return Result(id, new JObject());
if (method == "tools/list")
return Result(id, ToolsList(registry));
if (method == "tools/call")
return Result(id, ToolsCall(args, registry));
if (notification)
return null;
return Error(id, -32601, "unknown method " + method);
}
catch (Exception e)
{
Log.Error("[mcp] " + method + ": " + e);
if (notification) return null;
return Error(id, -32603, e.Message);
}
}
static JObject Initialize(JObject args)
{
string clientVer = args["protocolVersion"]?.Value<string>() ?? "2024-11-05";
string ver = clientVer;
if (ver != "2024-11-05" && ver != "2025-03-26" && ver != "2025-06-18")
ver = "2024-11-05";
string version = "s3";
try { version = Main.ModEntry.Version.ToString(); }
catch { }
return new JObject
{
["protocolVersion"] = ver,
["capabilities"] = new JObject
{
["tools"] = new JObject { ["listChanged"] = true },
},
["serverInfo"] = new JObject
{
["name"] = ServerName,
["version"] = version,
},
};
}
static JObject ToolsList(McpRegistry registry)
{
var tools = new JArray();
foreach (McpToolEntry t in registry.Visible())
{
tools.Add(new JObject
{
["name"] = t.Name,
["description"] = t.Description,
["inputSchema"] = t.InputSchema,
});
}
return new JObject { ["tools"] = tools };
}
static JObject ToolsCall(JObject args, McpRegistry registry)
{
string name = args["name"]?.Value<string>() ?? "";
JObject callArgs = args["arguments"] as JObject ?? new JObject();
var entry = registry.Find(name);
if (entry == null)
return CallError("unknown tool " + name);
var s = McpModule.Settings;
if (entry.Source != "host")
{
if (entry.Gate == McpGate.Observe && !s.observe)
return CallError("Observe gate is off");
if (entry.Gate == McpGate.Control && !s.control)
return CallError("Control gate is off");
if (entry.Gate == McpGate.Develop && !s.develop)
return CallError("Develop gate is off");
}
McpToolResult result;
try { result = entry.Handler(callArgs) ?? McpToolResult.Fail("null result"); }
catch (Exception e)
{
Log.Error("[mcp] tool " + name + ": " + e);
result = McpToolResult.Fail(e.Message);
}
var content = new JArray
{
new JObject { ["type"] = "text", ["text"] = result.Text ?? "" },
};
if (!string.IsNullOrEmpty(result.ImagePath) && File.Exists(result.ImagePath))
{
try
{
byte[] bytes = File.ReadAllBytes(result.ImagePath);
if (bytes.Length > 0 && bytes.Length < 1_800_000)
{
content.Add(new JObject
{
["type"] = "image",
["mimeType"] = "image/png",
["data"] = Convert.ToBase64String(bytes),
});
}
}
catch (Exception e)
{
content.Add(new JObject
{
["type"] = "text",
["text"] = "\n(image attach failed: " + e.Message + ")",
});
}
}
return new JObject
{
["content"] = content,
["isError"] = result.IsError,
};
}
static JObject CallError(string message) =>
new()
{
["content"] = new JArray
{
new JObject { ["type"] = "text", ["text"] = message },
},
["isError"] = true,
};
static JObject Result(JToken? id, JToken result)
{
var obj = new JObject
{
["jsonrpc"] = "2.0",
["result"] = result,
};
if (id != null && id.Type != JTokenType.Null)
obj["id"] = id;
return obj;
}
static JObject Error(JToken? id, int code, string message)
{
var obj = new JObject
{
["jsonrpc"] = "2.0",
["error"] = new JObject { ["code"] = code, ["message"] = message },
};
if (id != null && id.Type != JTokenType.Null)
obj["id"] = id;
return obj;
}
}

View file

@ -0,0 +1,63 @@
using System;
using Newtonsoft.Json.Linq;
namespace S3.Mcp;
/// <summary>
/// Public contract compiled into S3.dll. Reloadable tool assemblies
/// (S3.Mcp.Tools.dll and extra plugins) implement <see cref="IAgentPlugin"/>
/// and talk to the host only through these types.
/// </summary>
public enum McpGate
{
Observe = 0,
Control = 1,
Develop = 2,
}
public sealed class McpToolResult
{
public string Text = "";
public string? ImagePath;
public bool IsError;
public static McpToolResult Ok(string text) => new() { Text = text ?? "" };
public static McpToolResult Fail(string text) => new() { Text = text ?? "error", IsError = true };
public static McpToolResult Image(string path, string caption) =>
new() { Text = caption ?? "", ImagePath = path };
}
public interface IAgentPlugin
{
string Id { get; }
void Start(IMcpApi api);
void Stop();
}
public interface IMcpApi
{
bool Observe { get; }
bool Control { get; }
bool Develop { get; }
IMcpGame Game { get; }
void Log(string msg);
void RegisterTool(
string name,
string description,
string inputSchemaJson,
McpGate gate,
Func<JObject, McpToolResult> handler);
}
public interface IMcpGame
{
bool InPlay { get; }
string StatusText();
string RunSlash(string command);
string Screenshot(int maxWidth);
string LogTail(int lines);
string ModulesText();
string ConsistsText(int limit);
string SelectCar(string id);
string FreezeSelected(bool freeze);
}

View file

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using S3.Mcp;
namespace S3.Modules.Mcp;
sealed class McpToolEntry
{
public string Name = "";
public string Description = "";
public JObject InputSchema = new();
public McpGate Gate;
public string Source = "host";
public Func<JObject, McpToolResult> Handler = _ => McpToolResult.Fail("no handler");
}
sealed class McpRegistry
{
readonly List<McpToolEntry> _tools = new();
public IReadOnlyList<McpToolEntry> All => _tools;
public void ClearSource(string source)
{
for (int i = _tools.Count - 1; i >= 0; i--)
if (_tools[i].Source == source)
_tools.RemoveAt(i);
}
public void Add(McpToolEntry entry)
{
for (int i = _tools.Count - 1; i >= 0; i--)
if (_tools[i].Name == entry.Name)
_tools.RemoveAt(i);
_tools.Add(entry);
}
public McpToolEntry? Find(string name)
{
for (int i = 0; i < _tools.Count; i++)
if (_tools[i].Name == name)
return _tools[i];
return null;
}
public List<McpToolEntry> Visible()
{
var s = McpModule.Settings;
var list = new List<McpToolEntry>();
for (int i = 0; i < _tools.Count; i++)
{
var t = _tools[i];
if (t.Source == "host") { list.Add(t); continue; }
if (t.Gate == McpGate.Observe && !s.observe) continue;
if (t.Gate == McpGate.Control && !s.control) continue;
if (t.Gate == McpGate.Develop && !s.develop) continue;
list.Add(t);
}
return list;
}
}

View file

@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using Newtonsoft.Json.Linq;
using S3.Core;
namespace S3.Modules.Mcp;
sealed class McpServer
{
readonly Func<JObject, JObject?> _dispatch;
readonly string _token;
TcpListener? _listener;
Thread? _thread;
volatile bool _run;
public int BoundPort { get; private set; }
public string LastError { get; private set; } = "";
public bool Running => _run;
public McpServer(string token, Func<JObject, JObject?> dispatch)
{
_token = token ?? "";
_dispatch = dispatch;
}
public bool Start(int preferredPort)
{
Stop();
Exception? last = null;
int port = preferredPort;
for (int i = 0; i < 5; i++)
{
try
{
var listener = new TcpListener(IPAddress.Loopback, port + i);
listener.Start();
_listener = listener;
BoundPort = port + i;
_run = true;
LastError = "";
_thread = new Thread(AcceptLoop) { IsBackground = true, Name = "S3-MCP" };
_thread.Start();
Log.Info($"[mcp] listening on 127.0.0.1:{BoundPort}");
return true;
}
catch (Exception e)
{
last = e;
}
}
LastError = last?.Message ?? "bind failed";
Log.Error("[mcp] bind failed: " + LastError);
return false;
}
public void Stop()
{
_run = false;
try { _listener?.Stop(); }
catch { }
_listener = null;
if (_thread != null && _thread.IsAlive)
{
if (!_thread.Join(500))
try { _thread.Interrupt(); }
catch { }
}
_thread = null;
}
void AcceptLoop()
{
while (_run)
{
TcpClient? client = null;
try
{
client = _listener?.AcceptTcpClient();
}
catch (SocketException)
{
if (!_run) break;
continue;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception e)
{
if (_run) Log.Warn("[mcp] accept: " + e.Message);
continue;
}
if (client == null) continue;
TcpClient captured = client;
ThreadPool.QueueUserWorkItem(_ => HandleClient(captured));
}
}
void HandleClient(TcpClient client)
{
try
{
client.NoDelay = true;
client.ReceiveTimeout = 20000;
client.SendTimeout = 20000;
using (client)
using (NetworkStream stream = client.GetStream())
{
for (int n = 0; n < 32 && _run; n++)
{
if (!TryReadHttp(stream, out string method, out string path, out var headers, out byte[] body))
break;
string conn = Header(headers, "connection");
bool close = conn.IndexOf("close", StringComparison.OrdinalIgnoreCase) >= 0;
HandleOne(stream, method, path, headers, body);
if (close) break;
if (string.Equals(Header(headers, "http-version-close"), "1", StringComparison.Ordinal))
break;
}
}
}
catch (Exception e)
{
if (_run) Log.Warn("[mcp] client: " + e.Message);
}
}
void HandleOne(NetworkStream stream, string method, string path, Dictionary<string, string> headers, byte[] body)
{
if (path != "/mcp" && path != "/health" && path != "/")
{
WriteHttp(stream, 404, "text/plain", Encoding.UTF8.GetBytes("not found"));
return;
}
if (method == "GET")
{
WriteHttp(stream, 200, "text/plain", Encoding.UTF8.GetBytes("s3-mcp ok"));
return;
}
if (method != "POST")
{
WriteHttp(stream, 405, "text/plain", Encoding.UTF8.GetBytes("POST only"));
return;
}
if (!string.IsNullOrEmpty(_token))
{
string auth = Header(headers, "authorization");
string expect = "Bearer " + _token;
if (!string.Equals(auth, expect, StringComparison.Ordinal))
{
WriteHttp(stream, 401, "text/plain", Encoding.UTF8.GetBytes("unauthorized"));
return;
}
}
JObject req;
try
{
req = JObject.Parse(Encoding.UTF8.GetString(body));
}
catch (Exception e)
{
WriteHttp(stream, 400, "application/json",
Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700,\"message\":\"" +
Escape(e.Message) + "\"}}"));
return;
}
bool hasId = req["id"] != null && req["id"]!.Type != JTokenType.Null;
JObject? resp = _dispatch(req);
if (!hasId)
{
WriteHttp(stream, 202, "text/plain", Array.Empty<byte>());
return;
}
if (resp == null)
resp = new JObject { ["jsonrpc"] = "2.0", ["id"] = req["id"], ["result"] = new JObject() };
byte[] json = Encoding.UTF8.GetBytes(resp.ToString(Newtonsoft.Json.Formatting.None));
WriteHttp(stream, 200, "application/json", json);
}
static string Escape(string s) => (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"");
static string Header(Dictionary<string, string> headers, string name) =>
headers.TryGetValue(name, out string v) ? v : "";
static void WriteHttp(NetworkStream stream, int status, string contentType, byte[] body)
{
string reason = status == 200 ? "OK" : status == 202 ? "Accepted" : status == 401 ? "Unauthorized"
: status == 404 ? "Not Found" : status == 405 ? "Method Not Allowed" : "Error";
var sb = new StringBuilder();
sb.Append("HTTP/1.1 ").Append(status).Append(' ').Append(reason).Append("\r\n");
sb.Append("Content-Type: ").Append(contentType).Append("\r\n");
sb.Append("Content-Length: ").Append(body.Length).Append("\r\n");
sb.Append("Connection: keep-alive\r\n");
sb.Append("\r\n");
byte[] head = Encoding.ASCII.GetBytes(sb.ToString());
stream.Write(head, 0, head.Length);
if (body.Length > 0)
stream.Write(body, 0, body.Length);
stream.Flush();
}
static bool TryReadHttp(
NetworkStream stream,
out string method,
out string path,
out Dictionary<string, string> headers,
out byte[] body)
{
method = "";
path = "";
headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
body = Array.Empty<byte>();
var ms = new MemoryStream();
var buf = new byte[1024];
int headerEnd = -1;
while (headerEnd < 0 && ms.Length < 64 * 1024)
{
int n;
try { n = stream.Read(buf, 0, buf.Length); }
catch { return false; }
if (n <= 0) return false;
ms.Write(buf, 0, n);
byte[] soFar = ms.ToArray();
headerEnd = IndexOfHeaderEnd(soFar, soFar.Length);
}
if (headerEnd < 0) return false;
byte[] all = ms.ToArray();
string headerText = Encoding.ASCII.GetString(all, 0, headerEnd);
string[] lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None);
if (lines.Length == 0) return false;
string[] req = lines[0].Split(' ');
if (req.Length < 2) return false;
method = req[0].ToUpperInvariant();
path = req[1];
int q = path.IndexOf('?');
if (q >= 0) path = path.Substring(0, q);
for (int i = 1; i < lines.Length; i++)
{
int colon = lines[i].IndexOf(':');
if (colon <= 0) continue;
string key = lines[i].Substring(0, colon).Trim();
string val = lines[i].Substring(colon + 1).Trim();
headers[key] = val;
}
int extra = all.Length - (headerEnd + 4);
int length = 0;
if (headers.TryGetValue("Content-Length", out string cl))
int.TryParse(cl, out length);
if (length < 0) length = 0;
if (length > 1_000_000) return false;
var bodyBuf = new MemoryStream();
if (extra > 0)
bodyBuf.Write(all, headerEnd + 4, extra);
while (bodyBuf.Length < length)
{
int need = length - (int)bodyBuf.Length;
int n;
try { n = stream.Read(buf, 0, Math.Min(buf.Length, need)); }
catch { return false; }
if (n <= 0) return false;
bodyBuf.Write(buf, 0, n);
}
body = bodyBuf.ToArray();
if (body.Length > length && length > 0)
{
var trimmed = new byte[length];
Buffer.BlockCopy(body, 0, trimmed, 0, length);
body = trimmed;
}
return true;
}
static int IndexOfHeaderEnd(byte[] data, int len)
{
for (int i = 0; i <= len - 4; i++)
{
if (data[i] == 13 && data[i + 1] == 10 && data[i + 2] == 13 && data[i + 3] == 10)
return i;
}
return -1;
}
}

View file

@ -0,0 +1,19 @@
using System;
namespace S3.Modules.Mcp;
[Serializable]
public class McpSettings
{
public bool enabled = false;
public int port = 18765;
public string token = "s3-local-dev";
public bool observe = true;
public bool control = false;
public bool develop = true;
public int callTimeoutMs = 8000;
public bool autoReload = true;
}

View file

@ -0,0 +1,80 @@
using S3.Mcp;
using UnityEngine;
namespace S3.Modules.Mcp;
static class McpSettingsUI
{
public static void Draw()
{
var s = McpModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>MCP</b> - localhost agent socket. Tools live in a reloadable DLL.");
GUILayout.Space(4f);
GUILayout.Label(
" Cursor talks to this process over HTTP MCP on 127.0.0.1.\n" +
" Rebuild tools while the game is running: dist\\build-mcp-tools.ps1\n" +
" The host reloads S3.Mcp.Tools.dll automatically (or /s3mcp reload).\n" +
" Changing the host itself (this module in S3.dll) still needs a game restart.",
GUI.skin.label);
GUILayout.Space(8f);
var host = McpHost.Instance;
string listen = host != null ? host.ListenSummary : "(not running this launch)";
GUILayout.Label("Status: " + listen);
GUILayout.Label("URL: " + (host != null ? host.Url : "-"));
GUILayout.Label("Token: " + s.token);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Reload tools", GUILayout.Width(140f)))
host?.RequestReload(restartServer: true);
if (GUILayout.Button("Restart server", GUILayout.Width(140f)))
host?.RequestRestart();
GUILayout.EndHorizontal();
GUILayout.Space(10f);
GUILayout.Label("<b>Gates</b> (only from this panel, not via MCP)");
changed |= Toggle(ref s.observe, " Observe - read state, dumps, hover, screenshot");
changed |= Toggle(ref s.control, " Control - mutate the live save (freeze, select)");
changed |= Toggle(ref s.develop, " Develop - extra plugin DLLs besides S3.Mcp.Tools");
GUILayout.Space(10f);
GUILayout.Label("<b>Listen</b>");
GUILayout.BeginHorizontal();
GUILayout.Label("Port", GUILayout.Width(80f));
string portStr = GUILayout.TextField(s.port.ToString(), GUILayout.Width(80f));
if (int.TryParse(portStr, out int p) && p != s.port && p > 0 && p < 65536)
{
s.port = p;
changed = true;
}
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Token", GUILayout.Width(80f));
string tok = GUILayout.TextField(s.token ?? "", GUILayout.Width(280f));
if (tok != s.token)
{
s.token = tok;
changed = true;
}
GUILayout.EndHorizontal();
changed |= Toggle(ref s.autoReload, " Auto-reload S3.Mcp.Tools.dll when the file changes");
GUILayout.EndVertical();
if (changed)
McpModule.Persist();
}
static bool Toggle(ref bool value, string label)
{
bool next = GUILayout.Toggle(value, label);
if (next == value) return false;
value = next;
return true;
}
}