se-mcp
Health Warn
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Fail
- rm -rf — Recursive force deletion command in clean.sh
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Turn a running Space Engineers game into an MCP server
SeMcp
Turn a running Space Engineers game into an
MCP server — SE1 (client or dedicated
server) and SE2 (client). An LLM connects over local HTTP and executes C#
directly inside the live engine, with full .NET and game API access — right
down to internal types and members.
⚠️ This is remote code execution by design
The bearer token is the root password. Anyone holding it can run arbitrary
C# in the game process: file I/O, spawning processes, native[DllImport]—
the lot. There is no sandbox.
- Never share, screenshot, or commit the token.
- Never port-forward the listener or expose it past
localhost.- The server answers
http://localhost:<port>from this machine only: the
port may listen on every interface, but other machines and any otherHost(127.0.0.1included) are refused. Browser-origin (CSRF) requests
are rejected and the token is compared in constant time. In multiplayer
the client additionally gates execution (SE1: Admin/Owner promote
level; SE2: host / local-server sessions only).- The per-script watchdog (1 s/frame, 700 KB stack) exists to stop runaway
loops from hanging the game thread. It is not a security boundary — a
token holder already has full RCE.
What it is
MCP client (Claude, etc.)
│ HTTP · JSON-RPC · Bearer token (localhost only)
▼
┌──────────────────────────────┐
│ McpServer (Shared) │ HttpListener · JSON-RPC · auth · sessions
│ ITool dispatch │
│ Executor · Compiler · Guard │ Roslyn compile → Cecil IL guard → coroutine
└──────────────────────────────┘
│ runs your C#
┌─────┴───────────────┬───────────────────────┐
▼ ▼ ▼
main lane render lane (client) parallel lane
per-frame pump postfix on the render a thread per script,
(game / API state) thread (hook per host) concurrent, off-frame
Three hosts share one MCP core (Shared): SE1 ClientPlugin (Pulsar,
in-game), SE1 ServerPlugin (Magnetar, dedicated server), and SE2Client2Plugin (Pulsar Modern, in-game — ships as SeMcp2). Code runs on the
game's main thread, on either client's render thread, or on a thread of its
own (parallel). The per-frame pump differs by host:
- main lane — a Harmony postfix on the main thread's frame: SE1 (client and
server)MySandboxGame.Update, SE2VRageCore.Update. - render lane (client only) — SE1 a Harmony postfix on
MyRenderThread.RenderFrame; SE2 onRender12EngineComponent.RenderFrame. - Every pump postfix runs at Harmony priority
int.MinValue, after every other
plugin's postfix on the same method, so a script sees the frame fully settled. - parallel lane — no pump: each script runs to its end on a thread of its own,
concurrently with the game.
execute_code works on all three; take_screenshot and the render lane are
client-only.
Connecting
On first launch the plugin auto-generates a token. Where to find it and the URL:
- Client — open the in-game settings dialog and click Copy URL. You get
http://localhost:9876/?token=<token>on the clipboard. (Default port9876, or6789on the SE2 client; if taken it climbs9876→9885— the live
port shows in the dialog title and the log linelistening on :<port>.) - Server — on Quasar-managed servers, open the Plugin configuration page
to view the token and edit the port. Standalone servers without Quasar can read
the token from<UserDataPath>/SeMcp.cfg. Default port9000; same9000→9009climb if taken, with the bound port in the log.
Use
localhost, not127.0.0.1— theHostheader is checked and a
mismatch is refused.
Transport is MCP Streamable HTTP (not SSE): POST JSON-RPC tohttp://localhost:<port>/. Auth is either an Authorization: Bearer <token>
header or a ?token=<token> query parameter (not both). Point any standard
MCP client at it — it will initialize, pick up the Mcp-Session-Id, and manage
the session for you:
{
"mcpServers": {
"se-mcp": {
"type": "streamable-http",
"url": "http://localhost:9876/",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
Driving the raw protocol by hand (note: every call after initialize must echo
the session id):
# 1. initialize — the Mcp-Session-Id comes back in the response headers
curl -i http://localhost:9876/ \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# 2. call a tool
curl http://localhost:9876/ \
-H 'Authorization: Bearer <token>' -H 'Mcp-Session-Id: <id>' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"execute_code",
"arguments":{"code":"Console.WriteLine(MySession.Static?.Name);"}}}'
execute_code
Your input maps 1:1 onto three C# layers, which are spliced into a wrapper:
// <usings> ← extra "using" lines (defaults already imported)
public class __REPL__
{
static TextWriter Console => …; // your output — from code, class_body, nested types, any thread
// <class_body> ← methods, fields, nested types, [DllImport] — class-level
public IEnumerable<object> Run()
{
// <code> ← statements only; this is the entry point
yield break;
}
}
| field | required | what goes in it |
|---|---|---|
code |
yes | Statements only. Output via Console.WriteLine(). Pause until the next frame with yield return null. |
class_body |
no | Class-level declarations — anything that can't live in a method body, e.g. [DllImport] P/Invoke. |
usings |
no | Extra namespace imports — bare paths like "System.Runtime.InteropServices", no using keyword, no ;. |
target |
no | "main" (default), "render" (client only) or "parallel". |
- A large set of namespaces is pre-imported — SE1:
System.*,VRageMath,VRage.*,Sandbox.*,SpaceEngineers.Game.*; SE2:System.*,Keen.VRage.*,Keen.Game2.*. Use short type names (SE1MySession.Static,
SE2GameAppComponent), not fully-qualified ones. - Compiled with Roslyn 5.3 against every loaded assembly (.NET + game +
other plugins) and every one the game can still load, each at the file the
runtime will load it from, with ignore-accessibility turned on:internal
classes, methods, fields and properties are callable directly, no reflection
— this works across the game's own assemblies and other loaded plugins (only
trulyprivatemembers still need reflection).unsafeand[DllImport]are
allowed. Where several assemblies define the same type, a public copy hides the
copies from lower tiers (framework, then game, then everything else) and the
internal ones from its own, unless they carry extension methods it lacks. An
assembly not loaded yet that would still change what a name already means in a
script is reached through an extern alias instead, its name with_for.:Foo_Bar::Namespace.Type. - Compile errors come back per field with corrected line numbers:
code(3,9): error CS0103: .... - Scripts are coroutines and run in parallel; each step is bounded by the
watchdog above. The 1 s is one budget per frame, shared by every script
stepped in it: the script running when it runs out is killed (the report says
how long its own step took, so a bystander can be told from the culprit), and
scripts whose turn comes after that end with a retry error instead. - Hanging up cancels the script within about 2 s, as
notifications/cancelled
would: while it runs, a space goes ahead of the JSON every second, and the
first one that can't be written cancels it. Only the direct TCP peer is seen,
so keeplocalhostinNO_PROXY. parallelhas no frame budget:yield return nullresumes at once. A cancel
or hang-up answers at once and aborts the script's thread, so a loop with no
check point in it stops too; code stuck in afinally, acatchor a native
call can't be stopped.awaitworks onparallel, and only there — its entry isasync IAsyncEnumerable<object> Run(). Whatever a script awaits, it resumes on
its own thread, the one a cancel aborts: even afterConfigureAwait(false), and
even when an SE2 engine task completes on the game thread. Don't block on your
own async methods there (.Result,.Wait()): their continuations wait for the
very thread you're blocking, until a cancel frees it. Methods returning SE2's
ownTasktype keep Keen's rules.- On every lane, a script's faulted
Taskthat nobody awaits stays quiet (SE2
treats an unobserved one as a crash), and anasync voidmethod's exception
becomes its script's error onparallel, a log line elsewhere.
Examples
Read game state on the main thread:
var s = MyAPIGateway.Session;
Console.WriteLine($"World: {s.Name}");
Console.WriteLine($"You are at: {s.Player?.GetPosition()}");
The same on SE2 — the API root is GameAppComponent, reached through the
engine singleton, not MyAPIGateway:
var engine = Singleton<VRageCore>.Instance.Engine;
var session = engine.Get<GameAppComponent>().ClientSession;
Console.WriteLine($"in a session: {session != null}");
P/Invoke via class_body + usings (usings: ["System.Runtime.InteropServices"]):
// class_body
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
// code
MessageBox(IntPtr.Zero, "Hello from Space Engineers", "SeMcp", 0);
Console.WriteLine("shown");
Spread work across frames:
for (int i = 3; i > 0; i--)
{
Console.WriteLine($"tick {i}");
yield return null; // resume next frame
}
Console.WriteLine("done");
The
rendertarget runs on the render thread — use it only to inspect
other plugins' Harmony hooks that execute there. The game API (SE1MyAPIGateway, SE2 session / scene access) asserts off the main thread.
Both clients also expose take_screenshot (captures the current frame as an
image; optional ignore_sprites to drop the HUD). Full parameters are in the
tool's inputSchema.
Client vs. server
| SE1 client (Pulsar) | SE1 server (Magnetar) | SE2 client (Pulsar Modern) | |
|---|---|---|---|
| Default port | 9876 (9876–9885) |
9000 (9000–9009) |
6789 (6789–6798) |
render lane |
✅ | ❌ | ✅ |
parallel lane |
✅ | ✅ | ✅ |
take_screenshot |
✅ | ❌ | ✅ |
| Multiplayer gate | Admin/Owner required | none — token is full access | host / local-server only¹ |
| Settings GUI | ✅ (MyGui) | ✅ (Quasar Plugin config) | ✅ (Avalonia) |
| Config file | <UserDataPath>/Storage/SeMcp.cfg |
<UserDataPath>/SeMcp.cfg |
Pulsar Data\SeMcp2\SeMcp2.cfg |
| API root | MyAPIGateway / MySession |
same | GameAppComponent (Keen.*) |
¹ SE2 has no multiplayer yet; the gate pre-emptively blocks a pure client (one with
no local authoritative server), so in practice it only ever runs single-player today.
Configuration
Port and SecretKey persist to the .cfg (auto-saved). On either client both are
editable in the settings dialog, with Regenerate Token and Copy URL buttons;
on the server they are editable through Quasar's Plugin configuration page.
Changing the port requires a restart.
How it works
For anyone reading or extending the code:
McpServer— oneHttpListener, single (non-batched) JSON-RPC, auth +
loopback-peer/CSRF/host checks, pre-renderedtools/list. Sessions namespace in-flight
request ids but carry no server state.Executor— compilation runs on the thread pool; the compiled coroutine is
then stepped oneMoveNextper frame on its owning game thread. Each lane has
its own executor andScriptGuard, so finally-blocks and thread-affine state
stay on the right thread.ParallelExecutor— the parallel lane. Scripts compile withStackCheck
only and run insideControlledExecution.Run(net48: a port of it), each with aScriptPump: the queue of its continuations, which its thread runs until the
script completes. A kill answers the request first, drops the continuations
still to come, then aborts from a throwaway thread and interrupts managed waits
— at once, then every 250 ms — which an abort alone can't wake on .NET 10.Compiler— drives Roslyn entirely through reflection (no compile-time
binding, so it works against both the game's ancient Roslyn and the NuGet 5.3
one), references whatScriptReferencescollects (keeping only each file's
metadata in memory), and enables ignore-accessibility so
scripts can reachinternalmembers:MetadataImportOptions.Internal+BinderFlags.IgnoreAccessibilityat compile time, plus an injected[assembly: IgnoresAccessChecksTo]per referenced assembly at runtime (its
attribute is self-declared — source wins over the copies Harmony and other
plugins ship, so no ambiguity). It then rewrites the emitted IL with
Mono.Cecil to inject the guard, and points the script's reference to each
BCL async method builder at SeMcp's own.ScriptBuilders— those builders, member for member the same as the BCL's,
so retargeting one type reference swaps the builder of every async method in a
script without touching an instruction.async ValueTaskmethods run on theTaskones: their one call that namesValueTaskis rewritten to make it from
theTask, so the plugin never names aValueTaskof its own. On a parallel
script's thread they wrap each awaiter, sending its continuation through the
script'sScriptPump; anywhere else they pass the script's own call through.
A faultedTaskof theirs counts as observed.ScriptReferences— what scripts compile against: for each assembly name,
the first file the runtime's binder would come to (loaded by the main lane's
first frame, then TPA on .NET 10, the loader's library folder, the game folder,
the runtime folder on .NET Framework), read as metadata only, on a thread of its
own; the lanes open once it's done. Of a type defined in several, the copies
another one beats are hidden: Roslyn reads their files from a copy of the
metadata that marks them[Microsoft.CodeAnalysis.Embedded](HiddenTypes),
which keeps a type out of lookup. One that would still change what a name
already means in a script (a type's full name defined a second time, or a type
with no namespace hiding the one ausingbrings in) goes in under an extern
alias.ScriptGuard— a stack-depthStackCheckand aBail()at the entry of
every script method,Bail()on backward branches, andcatch→filter rewrites
(so acatchcan't swallow the abort). Entry checks also cover script code the
BCL or the game calls back into — an overriddenToString, a lambda handed to
LINQ, a Harmony patch — so recursion that detours through foreign code is
stopped, not a stack overflow. Each script bakes its own id into those checks;
when a frame's 1 s budget runs out, the lane'sFrameWatchdograises the id of
the script on the stack (KillId), and the checks answer only on the lane's own
thread — so only that script is aborted, only there: never another script, never
the same script's code on other threads, and never the Harmony patches or
handlers an earlier script left behind.
License
MIT — see LICENSE.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found