using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Threading; using BepInEx; using BepInEx.Logging; using HarmonyLib; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")] [assembly: AssemblyCompany("ValheimMCP")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("0.1.0.0")] [assembly: AssemblyInformationalVersion("0.1.0+1050b1c647d83d8e7bf4868e987daee96df7d72f")] [assembly: AssemblyProduct("ValheimMCP")] [assembly: AssemblyTitle("ValheimMCP")] [assembly: AssemblyVersion("0.1.0.0")] namespace ValheimMCP; internal sealed class RenderResult { public byte[] Png; public string Error; } internal static class CameraRenderer { private const string CamName = "valheimmcp_render_cam"; private static Camera _sCam; public static RenderResult Render(float x, float z, float? y, float yaw, float pitch, float dist, int size) { //IL_0068: Unknown result type (might be due to invalid IL or missing references) //IL_0069: Unknown result type (might be due to invalid IL or missing references) //IL_0077: Unknown result type (might be due to invalid IL or missing references) //IL_007c: Unknown result type (might be due to invalid IL or missing references) //IL_0081: Unknown result type (might be due to invalid IL or missing references) //IL_0091: Unknown result type (might be due to invalid IL or missing references) //IL_009f: Unknown result type (might be due to invalid IL or missing references) //IL_00a0: Unknown result type (might be due to invalid IL or missing references) //IL_00a2: Unknown result type (might be due to invalid IL or missing references) //IL_00a7: Unknown result type (might be due to invalid IL or missing references) //IL_00ac: Unknown result type (might be due to invalid IL or missing references) //IL_0106: Unknown result type (might be due to invalid IL or missing references) //IL_010d: Expected O, but got Unknown //IL_011f: Unknown result type (might be due to invalid IL or missing references) try { size = ModConfig.ClampRenderSize(size); float num = y ?? SampleGround(x, z); Vector3 val = default(Vector3); ((Vector3)(ref val))..ctor(x, num, z); float num2 = pitch * ((float)Math.PI / 180f); float num3 = yaw * ((float)Math.PI / 180f); Vector3 val2 = default(Vector3); ((Vector3)(ref val2))..ctor(Mathf.Cos(num2) * Mathf.Sin(num3), Mathf.Sin(num2), Mathf.Cos(num2) * Mathf.Cos(num3)); Vector3 val3 = val + val2 * Mathf.Max(1f, dist); Camera val4 = EnsureCamera(); ((Component)val4).transform.position = val3; ((Component)val4).transform.rotation = Quaternion.LookRotation(val - val3, Vector3.up); val4.nearClipPlane = 0.1f; val4.farClipPlane = dist + 1000f; RenderTexture temporary = RenderTexture.GetTemporary(size, size, 24, (RenderTextureFormat)0); RenderTexture active = RenderTexture.active; Texture2D val5 = null; try { val4.targetTexture = temporary; val4.Render(); RenderTexture.active = temporary; val5 = new Texture2D(size, size, (TextureFormat)3, false); val5.ReadPixels(new Rect(0f, 0f, (float)size, (float)size), 0, 0); val5.Apply(); byte[] png = ImageConversion.EncodeToPNG(val5); return new RenderResult { Png = png }; } finally { val4.targetTexture = null; RenderTexture.active = active; RenderTexture.ReleaseTemporary(temporary); if ((Object)(object)val5 != (Object)null) { Object.Destroy((Object)(object)val5); } } } catch (Exception ex) { return new RenderResult { Error = ex.Message }; } } private static Camera EnsureCamera() { //IL_0018: Unknown result type (might be due to invalid IL or missing references) //IL_001d: Unknown result type (might be due to invalid IL or missing references) //IL_0025: Unknown result type (might be due to invalid IL or missing references) //IL_002b: Expected O, but got Unknown if ((Object)(object)_sCam != (Object)null) { return _sCam; } GameObject val = new GameObject("valheimmcp_render_cam") { hideFlags = (HideFlags)61 }; Object.DontDestroyOnLoad((Object)val); _sCam = val.AddComponent(); ((Behaviour)_sCam).enabled = false; _sCam.clearFlags = (CameraClearFlags)1; _sCam.cullingMask = -1; _sCam.fieldOfView = 60f; return _sCam; } private static float SampleGround(float x, float z) { //IL_0017: Unknown result type (might be due to invalid IL or missing references) ZoneSystem instance = ZoneSystem.instance; float result = default(float); if ((Object)(object)instance != (Object)null && instance.GetGroundHeight(new Vector3(x, 5000f, z), ref result)) { return result; } return 0f; } } internal sealed class CommandResult { public bool Ok; public string Error; public List Output = new List(); } internal static class ConsoleBridge { private static readonly FieldInfo CommandsField = typeof(Terminal).GetField("commands", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); public static bool IsReady => (Object)(object)Console.instance != (Object)null; public static List> ListCommands() { List> list = new List>(); if (CommandsField?.GetValue(null) is IDictionary dictionary) { foreach (DictionaryEntry item in dictionary) { string text = item.Key as string; object? value = item.Value; string value2 = ((ConsoleCommand)(((value is ConsoleCommand) ? value : null)?)).Description ?? ""; if (text != null) { list.Add(new KeyValuePair(text, value2)); } } } return list.OrderBy, string>((KeyValuePair c) => c.Key, StringComparer.Ordinal).ToList(); } public static CommandResult Run(string commandLine) { if (!ModConfig.IsCommandAllowed(commandLine, out var reason)) { return new CommandResult { Ok = false, Error = reason }; } Console instance = Console.instance; if ((Object)(object)instance == (Object)null) { return new CommandResult { Ok = false, Error = "Console.instance is null (no game loaded yet)" }; } ConsoleOutputCapture.Begin(); try { ((Terminal)instance).TryRunCommand(commandLine, false, true); } catch (Exception ex) { return new CommandResult { Ok = false, Error = "command threw: " + ex.Message, Output = ConsoleOutputCapture.End() }; } return new CommandResult { Ok = true, Output = ConsoleOutputCapture.End() }; } } [HarmonyPatch] internal static class ConsoleOutputCapture { private static List _sink; public static void Begin() { _sink = new List(); } public static List End() { List result = _sink ?? new List(); _sink = null; return result; } [HarmonyPostfix] [HarmonyPatch(typeof(Terminal), "AddString", new Type[] { typeof(string) })] private static void Terminal_AddString_Postfix(string text) { _sink?.Add(text); } } internal sealed class HttpServer { private readonly HttpListener _listener = new HttpListener(); private readonly int _commandTimeoutMs; private Thread _thread; private volatile bool _running; public HttpServer(string prefix, int commandTimeoutMs) { _listener.Prefixes.Add(prefix); _commandTimeoutMs = commandTimeoutMs; } public void Start() { _listener.Start(); _running = true; _thread = new Thread(Loop) { IsBackground = true, Name = "ValheimMCP-http" }; _thread.Start(); } public void Stop() { _running = false; try { _listener.Stop(); _listener.Close(); } catch { } } private void Loop() { while (_running) { HttpListenerContext context; try { context = _listener.GetContext(); } catch { if (!_running) { break; } continue; } try { Handle(context); } catch (Exception ex) { ManualLogSource log = Plugin.Log; if (log != null) { log.LogError((object)$"[ValheimMCP] request handler threw: {ex}"); } Write(context, 500, Json.Error(ex.Message)); } } } private void Handle(HttpListenerContext ctx) { string text2 = ctx.Request.Url.AbsolutePath.TrimEnd(new char[1] { '/' }); string httpMethod = ctx.Request.HttpMethod; switch (text2) { case "/mcp": if (httpMethod == "POST") { McpHttpReply mcpHttpReply = McpServer.HandleHttp(ReadBody(ctx.Request), _commandTimeoutMs); if (mcpHttpReply.HasBody) { Write(ctx, mcpHttpReply.Status, mcpHttpReply.Body); } else { WriteEmpty(ctx, mcpHttpReply.Status); } } else if (httpMethod == "DELETE") { WriteEmpty(ctx, 200); } else { Write(ctx, 405, Json.Error("MCP endpoint supports POST only (no SSE stream)")); } return; case "/sse": Write(ctx, 501, Json.Error("SSE transport not implemented; use POST /mcp (JSON-RPC over HTTP)")); return; case "": case "/health": { MainThreadDispatcher.RunBlocking(() => ConsoleBridge.IsReady, 2000, out var result2, out var _); Write(ctx, 200, Json.Health(result2)); return; } case "/commands": if (httpMethod == "GET") { if (!MainThreadDispatcher.RunBlocking(() => Json.Commands(ConsoleBridge.ListCommands()), 5000, out var result, out var error)) { Write(ctx, 504, Json.Error("timed out listing commands (game not ticking?)")); } else if (error != null) { Write(ctx, 500, Json.Error(error.Message)); } else { Write(ctx, 200, result); } return; } break; } if (text2 == "/command" && httpMethod == "POST") { string text = ReadCommandText(ctx.Request); CommandResult result3; Exception error3; if (string.IsNullOrWhiteSpace(text)) { Write(ctx, 400, Json.Error("missing command text (send as raw body or ?text=)")); } else if (!MainThreadDispatcher.RunBlocking(() => ConsoleBridge.Run(text), _commandTimeoutMs, out result3, out error3)) { Write(ctx, 504, Json.Error($"timed out after {_commandTimeoutMs}ms (game paused or command hung?)")); } else if (error3 != null) { Write(ctx, 500, Json.Error(error3.Message)); } else { Write(ctx, result3.Ok ? 200 : 500, Json.CommandResult(text, result3)); } } else { Write(ctx, 404, Json.Error("unknown route: " + httpMethod + " " + text2)); } } private static string ReadCommandText(HttpListenerRequest req) { string text = req.QueryString["text"]; if (!string.IsNullOrEmpty(text)) { return text.Trim(); } return ReadBody(req).Trim(); } private static string ReadBody(HttpListenerRequest req) { using StreamReader streamReader = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8); return streamReader.ReadToEnd(); } private static void WriteEmpty(HttpListenerContext ctx, int status) { try { ctx.Response.StatusCode = status; ctx.Response.ContentLength64 = 0L; ctx.Response.OutputStream.Close(); } catch (Exception ex) { ManualLogSource log = Plugin.Log; if (log != null) { log.LogWarning((object)("[ValheimMCP] failed to write empty response: " + ex.Message)); } } } private static void Write(HttpListenerContext ctx, int status, string json) { try { byte[] bytes = Encoding.UTF8.GetBytes(json); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } catch (Exception ex) { ManualLogSource log = Plugin.Log; if (log != null) { log.LogWarning((object)("[ValheimMCP] failed to write response: " + ex.Message)); } } } } internal static class Json { public static string Str(string s) { if (s == null) { return "null"; } StringBuilder stringBuilder = new StringBuilder(s.Length + 2); stringBuilder.Append('"'); foreach (char c in s) { switch (c) { case '"': stringBuilder.Append("\\\""); continue; case '\\': stringBuilder.Append("\\\\"); continue; case '\n': stringBuilder.Append("\\n"); continue; case '\r': stringBuilder.Append("\\r"); continue; case '\t': stringBuilder.Append("\\t"); continue; } if (c < ' ') { StringBuilder stringBuilder2 = stringBuilder.Append("\\u"); int num = c; stringBuilder2.Append(num.ToString("x4")); } else { stringBuilder.Append(c); } } stringBuilder.Append('"'); return stringBuilder.ToString(); } public static string Array(IEnumerable items) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append('['); bool flag = true; foreach (string item in items) { if (!flag) { stringBuilder.Append(','); } flag = false; stringBuilder.Append(Str(item)); } stringBuilder.Append(']'); return stringBuilder.ToString(); } public static string Error(string message) { return "{\"ok\":false,\"error\":" + Str(message) + "}"; } public static string Health(bool inGame) { return "{\"ok\":true,\"inGame\":" + (inGame ? "true" : "false") + "}"; } public static string Commands(IReadOnlyList> commands) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\"ok\":true,\"commands\":["); for (int i = 0; i < commands.Count; i++) { if (i > 0) { stringBuilder.Append(','); } stringBuilder.Append("{\"name\":").Append(Str(commands[i].Key)).Append(",\"description\":") .Append(Str(commands[i].Value)) .Append('}'); } stringBuilder.Append("]}"); return stringBuilder.ToString(); } public static string CommandResult(string ran, CommandResult result) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\"ok\":").Append(result.Ok ? "true" : "false").Append(",\"ran\":") .Append(Str(ran)) .Append(",\"output\":") .Append(Array(result.Output)); if (!result.Ok) { stringBuilder.Append(",\"error\":").Append(Str(result.Error)); } stringBuilder.Append('}'); return stringBuilder.ToString(); } } internal static class MainThreadDispatcher { private static readonly ConcurrentQueue Queue = new ConcurrentQueue(); public static bool RunBlocking(Func func, int timeoutMs, out T result, out Exception error) { T captured = default(T); Exception err = null; ManualResetEventSlim done = new ManualResetEventSlim(initialState: false); try { Queue.Enqueue(delegate { try { captured = func(); } catch (Exception ex) { err = ex; } finally { done.Set(); } }); bool result2 = done.Wait(timeoutMs); result = captured; error = err; return result2; } finally { if (done != null) { ((IDisposable)done).Dispose(); } } } public static void Pump() { Action result; while (Queue.TryDequeue(out result)) { try { result(); } catch (Exception arg) { ManualLogSource log = Plugin.Log; if (log != null) { log.LogError((object)$"[ValheimMCP] queued main-thread action threw: {arg}"); } } } } } internal struct McpHttpReply { public int Status; public string Body; public bool HasBody; } internal static class McpServer { public const string ServerName = "valheim-mcp"; public const string ServerVersion = "0.1.0"; private const string DefaultProtocol = "2024-11-05"; public static McpHttpReply HandleHttp(string body, int commandTimeoutMs) { object obj; try { obj = MiniJson.Parse(body); } catch (Exception ex) { return Json200(ErrorResponse(null, -32700, "Parse error: " + ex.Message)); } if (obj is List list) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append('['); bool flag = false; foreach (object item in list) { string text = HandleOne(item as Dictionary, commandTimeoutMs); if (text != null) { if (flag) { stringBuilder.Append(','); } stringBuilder.Append(text); flag = true; } } stringBuilder.Append(']'); if (!flag) { return Accepted(); } return Json200(stringBuilder.ToString()); } string text2 = HandleOne(obj as Dictionary, commandTimeoutMs); if (text2 != null) { return Json200(text2); } return Accepted(); } private static string HandleOne(Dictionary req, int commandTimeoutMs) { if (req == null) { return ErrorResponse(null, -32600, "Invalid Request"); } object value; string text = (req.TryGetValue("method", out value) ? (value as string) : null); object value2; bool flag = req.TryGetValue("id", out value2); if (text == null) { if (!flag) { return null; } return ErrorResponse(value2, -32600, "Missing method"); } switch (text) { case "initialize": return Result(value2, InitializeResult(req)); case "ping": return Result(value2, "{}"); case "tools/list": return Result(value2, ToolsListResult()); case "tools/call": return ToolsCall(value2, req, commandTimeoutMs); default: if (!flag) { return null; } return ErrorResponse(value2, -32601, "Method not found: " + text); } } private static string InitializeResult(Dictionary req) { string s = "2024-11-05"; if (req.TryGetValue("params", out var value) && value is Dictionary dictionary && dictionary.TryGetValue("protocolVersion", out var value2) && value2 is string text && text.Length > 0) { s = text; } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\"protocolVersion\":").Append(Json.Str(s)).Append(",\"capabilities\":{\"tools\":{}}") .Append(",\"serverInfo\":{\"name\":") .Append(Json.Str("valheim-mcp")) .Append(",\"version\":") .Append(Json.Str("0.1.0")) .Append("}}"); return stringBuilder.ToString(); } private static string ToolsListResult() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\"tools\":["); AppendTool(stringBuilder, "run_command", "Run a Valheim console command (e.g. 'pos' to print the player's position, or any registered command — call list_commands to discover them) and return the lines it printed to the in-game console.", "{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"description\":\"The full console command line to execute.\"}},\"required\":[\"text\"]}"); stringBuilder.Append(','); AppendTool(stringBuilder, "list_commands", "List all registered Valheim console commands with their descriptions.", "{\"type\":\"object\",\"properties\":{}}"); stringBuilder.Append(','); AppendTool(stringBuilder, "health", "Check whether Valheim is running with a world loaded (so commands can execute).", "{\"type\":\"object\",\"properties\":{}}"); stringBuilder.Append(','); AppendTool(stringBuilder, "render_view", "Render a PNG of the game world at a point, using an independent off-screen camera (does NOT move the player's view). Returns the image inline.", "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"World X of the look-at point.\"},\"z\":{\"type\":\"number\",\"description\":\"World Z of the look-at point.\"},\"y\":{\"type\":\"number\",\"description\":\"World Y of the look-at point. Defaults to terrain ground height; pass the feature/villager Y for interiors or elevated floors.\"},\"yaw\":{\"type\":\"number\",\"description\":\"Camera compass azimuth in degrees (default 45).\"},\"pitch\":{\"type\":\"number\",\"description\":\"Camera elevation above horizon in degrees: 0=level, 90=top-down (default 35).\"},\"dist\":{\"type\":\"number\",\"description\":\"Camera distance from the look-at point in meters (default 12).\"},\"size\":{\"type\":\"number\",\"description\":\"Square output size in pixels. Defaults to and is clamped by the mod config (render.defaultSize / minSize / maxSize).\"}},\"required\":[\"x\",\"z\"]}"); stringBuilder.Append("]}"); return stringBuilder.ToString(); } private static void AppendTool(StringBuilder sb, string name, string description, string schemaJson) { sb.Append("{\"name\":").Append(Json.Str(name)).Append(",\"description\":") .Append(Json.Str(description)) .Append(",\"inputSchema\":") .Append(schemaJson) .Append('}'); } private static string ToolsCall(object id, Dictionary req, int commandTimeoutMs) { if (!req.TryGetValue("params", out var value) || !(value is Dictionary dictionary)) { return ErrorResponse(id, -32602, "Invalid params"); } object value2; string text2 = (dictionary.TryGetValue("name", out value2) ? (value2 as string) : null); object value3; Dictionary dictionary2 = (dictionary.TryGetValue("arguments", out value3) ? (value3 as Dictionary) : null); if (string.IsNullOrEmpty(text2)) { return ErrorResponse(id, -32602, "Missing tool name"); } switch (text2) { case "health": { MainThreadDispatcher.RunBlocking(() => ConsoleBridge.IsReady, 2000, out var result4, out var _); return Result(id, ToolText(Json.Health(result4), isError: false)); } case "list_commands": { if (!MainThreadDispatcher.RunBlocking(() => Json.Commands(ConsoleBridge.ListCommands()), 5000, out var result3, out var error3)) { return Result(id, ToolText("timed out listing commands (game not ticking?)", isError: true)); } if (error3 != null) { return Result(id, ToolText(error3.Message, isError: true)); } return Result(id, ToolText(result3, isError: false)); } case "run_command": { object value7; string text = ((dictionary2 != null && dictionary2.TryGetValue("text", out value7)) ? (value7 as string) : null); if (string.IsNullOrWhiteSpace(text)) { return Result(id, ToolText("missing 'text' argument", isError: true)); } if (!MainThreadDispatcher.RunBlocking(() => ConsoleBridge.Run(text), commandTimeoutMs, out var result2, out var error2)) { return Result(id, ToolText($"timed out after {commandTimeoutMs}ms (game paused or command hung?)", isError: true)); } if (error2 != null) { return Result(id, ToolText(error2.Message, isError: true)); } string text3 = ((result2.Output.Count > 0) ? string.Join("\n", result2.Output) : "(no console output)"); if (!result2.Ok) { text3 = (result2.Error ?? "command failed") + ((result2.Output.Count > 0) ? ("\n" + string.Join("\n", result2.Output)) : ""); } return Result(id, ToolText(text3, !result2.Ok)); } case "render_view": { if (dictionary2 == null || !dictionary2.TryGetValue("x", out var value4) || !(value4 is double num) || !dictionary2.TryGetValue("z", out var value5) || !(value5 is double num2)) { return Result(id, ToolText("render_view requires numeric 'x' and 'z'", isError: true)); } float x = (float)num; float z = (float)num2; object value6; float? y = ((dictionary2.TryGetValue("y", out value6) && value6 is double num3) ? new float?((float)num3) : null); float yaw = (float)Num(dictionary2, "yaw", 45.0); float pitch = (float)Num(dictionary2, "pitch", 35.0); float dist = (float)Num(dictionary2, "dist", 12.0); int size = (int)Num(dictionary2, "size", ModConfig.RenderDefaultSize); if (!MainThreadDispatcher.RunBlocking(() => CameraRenderer.Render(x, z, y, yaw, pitch, dist, size), 20000, out var result, out var error)) { return Result(id, ToolText("render timed out (game not ticking?)", isError: true)); } if (error != null) { return Result(id, ToolText("render threw: " + error.Message, isError: true)); } if (result?.Png == null) { return Result(id, ToolText("render failed: " + (result?.Error ?? "unknown"), isError: true)); } return Result(id, ToolImage(Convert.ToBase64String(result.Png), "image/png")); } default: return ErrorResponse(id, -32602, "Unknown tool: " + text2); } } private static double Num(Dictionary args, string key, double dflt) { if (args != null && args.TryGetValue(key, out var value) && value is double) { return (double)value; } return dflt; } private static string ToolText(string text, bool isError) { return "{\"content\":[{\"type\":\"text\",\"text\":" + Json.Str(text) + "}],\"isError\":" + (isError ? "true" : "false") + "}"; } private static string ToolImage(string base64, string mimeType) { return "{\"content\":[{\"type\":\"image\",\"data\":" + Json.Str(base64) + ",\"mimeType\":" + Json.Str(mimeType) + "}],\"isError\":false}"; } private static string Result(object id, string resultJson) { return "{\"jsonrpc\":\"2.0\",\"id\":" + FormatId(id) + ",\"result\":" + resultJson + "}"; } private static string ErrorResponse(object id, int code, string message) { return "{\"jsonrpc\":\"2.0\",\"id\":" + FormatId(id) + ",\"error\":{\"code\":" + code.ToString(CultureInfo.InvariantCulture) + ",\"message\":" + Json.Str(message) + "}}"; } private static string FormatId(object id) { if (id == null) { return "null"; } if (id is string s) { return Json.Str(s); } if (id is bool) { if (!(bool)id) { return "false"; } return "true"; } if (id is double num) { if (!double.IsInfinity(num) && !double.IsNaN(num) && num == Math.Floor(num) && Math.Abs(num) < 9.2E+18) { return ((long)num).ToString(CultureInfo.InvariantCulture); } return num.ToString("R", CultureInfo.InvariantCulture); } return Json.Str(id.ToString()); } private static McpHttpReply Json200(string body) { McpHttpReply result = default(McpHttpReply); result.Status = 200; result.Body = body; result.HasBody = true; return result; } private static McpHttpReply Accepted() { McpHttpReply result = default(McpHttpReply); result.Status = 202; result.Body = null; result.HasBody = false; return result; } } internal static class MiniJson { public static object Parse(string text) { int i = 0; object result = ParseValue(text, ref i); SkipWs(text, ref i); return result; } private static object ParseValue(string s, ref int i) { SkipWs(s, ref i); if (i >= s.Length) { throw new FormatException("Unexpected end of JSON"); } switch (s[i]) { case '{': return ParseObject(s, ref i); case '[': return ParseArray(s, ref i); case '"': return ParseString(s, ref i); case 't': Expect(s, ref i, "true"); return true; case 'f': Expect(s, ref i, "false"); return false; case 'n': Expect(s, ref i, "null"); return null; default: return ParseNumber(s, ref i); } } private static Dictionary ParseObject(string s, ref int i) { Dictionary dictionary = new Dictionary(); i++; SkipWs(s, ref i); if (i < s.Length && s[i] == '}') { i++; return dictionary; } while (true) { SkipWs(s, ref i); if (i >= s.Length || s[i] != '"') { throw new FormatException("Expected string key"); } string key = ParseString(s, ref i); SkipWs(s, ref i); if (i >= s.Length || s[i] != ':') { throw new FormatException("Expected ':'"); } i++; dictionary[key] = ParseValue(s, ref i); SkipWs(s, ref i); if (i >= s.Length) { throw new FormatException("Unterminated object"); } if (s[i] != ',') { break; } i++; } if (s[i] == '}') { i++; return dictionary; } throw new FormatException("Expected ',' or '}'"); } private static List ParseArray(string s, ref int i) { List list = new List(); i++; SkipWs(s, ref i); if (i < s.Length && s[i] == ']') { i++; return list; } while (true) { list.Add(ParseValue(s, ref i)); SkipWs(s, ref i); if (i >= s.Length) { throw new FormatException("Unterminated array"); } if (s[i] != ',') { break; } i++; } if (s[i] == ']') { i++; return list; } throw new FormatException("Expected ',' or ']'"); } private static string ParseString(string s, ref int i) { StringBuilder stringBuilder = new StringBuilder(); i++; while (i < s.Length) { char c = s[i++]; switch (c) { case '"': return stringBuilder.ToString(); case '\\': break; default: stringBuilder.Append(c); continue; } if (i >= s.Length) { break; } char c2 = s[i++]; switch (c2) { case '"': stringBuilder.Append('"'); break; case '\\': stringBuilder.Append('\\'); break; case '/': stringBuilder.Append('/'); break; case 'b': stringBuilder.Append('\b'); break; case 'f': stringBuilder.Append('\f'); break; case 'n': stringBuilder.Append('\n'); break; case 'r': stringBuilder.Append('\r'); break; case 't': stringBuilder.Append('\t'); break; case 'u': if (i + 4 > s.Length) { throw new FormatException("Bad \\u escape"); } stringBuilder.Append((char)int.Parse(s.Substring(i, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); i += 4; break; default: throw new FormatException("Bad escape: \\" + c2); } } throw new FormatException("Unterminated string"); } private static double ParseNumber(string s, ref int i) { int num = i; while (i < s.Length && "+-0123456789.eE".IndexOf(s[i]) >= 0) { i++; } string text = s.Substring(num, i - num); if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { throw new FormatException("Bad number: " + text); } return result; } private static void Expect(string s, ref int i, string literal) { if (i + literal.Length > s.Length || s.Substring(i, literal.Length) != literal) { throw new FormatException("Expected '" + literal + "'"); } i += literal.Length; } private static void SkipWs(string s, ref int i) { while (i < s.Length && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) { i++; } } } internal sealed class MiniYaml { private readonly Dictionary _scalars = new Dictionary(); private readonly Dictionary> _lists = new Dictionary>(); public static MiniYaml Parse(string text) { MiniYaml miniYaml = new MiniYaml(); string text2 = null; string text3 = null; string[] array = text.Replace("\r\n", "\n").Split(new char[1] { '\n' }); for (int i = 0; i < array.Length; i++) { string text4 = StripComment(array[i]); string text5 = text4.Trim(); if (text5.Length == 0) { continue; } int num = text4.Length - text4.TrimStart(new char[1] { ' ' }).Length; if (text5[0] == '-') { if (text3 != null) { string text6 = Unquote(text5.Substring(1).Trim()); if (text6.Length > 0) { miniYaml._lists[text3].Add(text6); } } continue; } int num2 = text5.IndexOf(':'); if (num2 < 0) { continue; } string text7 = text5.Substring(0, num2).Trim(); string text8 = text5.Substring(num2 + 1).Trim(); if (num == 0) { text3 = null; if (text8.Length == 0) { text2 = text7; continue; } miniYaml._scalars[text7] = Unquote(text8); text2 = null; continue; } string text9 = ((text2 != null) ? (text2 + "." + text7) : text7); if (text8.Length == 0) { text3 = text9; if (!miniYaml._lists.ContainsKey(text9)) { miniYaml._lists[text9] = new List(); } } else if (text8.StartsWith("[") && text8.EndsWith("]")) { string text10 = text8.Substring(1, text8.Length - 2); List list = new List(); string[] array2 = text10.Split(new char[1] { ',' }); for (int j = 0; j < array2.Length; j++) { string text11 = Unquote(array2[j].Trim()); if (text11.Length > 0) { list.Add(text11); } } miniYaml._lists[text9] = list; text3 = null; } else { miniYaml._scalars[text9] = Unquote(text8); text3 = null; } } return miniYaml; } public string Get(string path, string dflt) { if (!_scalars.TryGetValue(path, out var value)) { return dflt; } return value; } public int GetInt(string path, int dflt) { if (!_scalars.TryGetValue(path, out var value) || !int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) { return dflt; } return result; } public List GetList(string path) { if (!_lists.TryGetValue(path, out var value)) { return new List(); } return value; } private static string StripComment(string line) { string text = line.TrimStart(Array.Empty()); if (text.Length > 0 && text[0] == '#') { return ""; } int num = line.IndexOf(" #", StringComparison.Ordinal); if (num < 0) { return line; } return line.Substring(0, num); } private static string Unquote(string s) { if (s.Length >= 2 && ((s[0] == '"' && s[s.Length - 1] == '"') || (s[0] == '\'' && s[s.Length - 1] == '\''))) { return s.Substring(1, s.Length - 2); } return s; } } internal static class ModConfig { public const string FileName = "valheimmcp.yml"; public static string Host = "127.0.0.1"; public static int Port = 8731; public static int CommandTimeoutMs = 15000; public static int RenderDefaultSize = 768; public static int RenderMinSize = 256; public static int RenderMaxSize = 1280; private static List _allow = new List(); private static List _deny = new List(); private const string DefaultYaml = "# ValheimMCP configuration (YAML). Full-line comments (#) only.\n\nserver:\n host: 127.0.0.1 # loopback only — endpoint is unauthenticated, keep it local\n port: 8731\n commandTimeoutMs: 15000 # max wait for a command to run on the main thread\n\nrender:\n defaultSize: 768 # render_view size (px, square) when 'size' is omitted\n minSize: 256\n maxSize: 1280\n\n# Access control for run_command (and POST /command). 'deny' always wins. If\n# 'allow' is non-empty, ONLY matching commands may run. Match is by command name;\n# a trailing '*' is a prefix wildcard, e.g. \"spawn*\" matches every spawn command.\ncommands:\n allow: []\n deny: []\n"; public static void Load() { try { string text = Path.Combine(Paths.ConfigPath, "valheimmcp.yml"); if (!File.Exists(text)) { File.WriteAllText(text, "# ValheimMCP configuration (YAML). Full-line comments (#) only.\n\nserver:\n host: 127.0.0.1 # loopback only — endpoint is unauthenticated, keep it local\n port: 8731\n commandTimeoutMs: 15000 # max wait for a command to run on the main thread\n\nrender:\n defaultSize: 768 # render_view size (px, square) when 'size' is omitted\n minSize: 256\n maxSize: 1280\n\n# Access control for run_command (and POST /command). 'deny' always wins. If\n# 'allow' is non-empty, ONLY matching commands may run. Match is by command name;\n# a trailing '*' is a prefix wildcard, e.g. \"spawn*\" matches every spawn command.\ncommands:\n allow: []\n deny: []\n"); ManualLogSource log = Plugin.Log; if (log != null) { log.LogInfo((object)("[ValheimMCP] wrote default config: " + text)); } } MiniYaml miniYaml = MiniYaml.Parse(File.ReadAllText(text)); Host = miniYaml.Get("server.host", Host); Port = miniYaml.GetInt("server.port", Port); CommandTimeoutMs = miniYaml.GetInt("server.commandTimeoutMs", CommandTimeoutMs); RenderDefaultSize = miniYaml.GetInt("render.defaultSize", RenderDefaultSize); RenderMinSize = miniYaml.GetInt("render.minSize", RenderMinSize); RenderMaxSize = miniYaml.GetInt("render.maxSize", RenderMaxSize); _allow = miniYaml.GetList("commands.allow"); _deny = miniYaml.GetList("commands.deny"); ManualLogSource log2 = Plugin.Log; if (log2 != null) { log2.LogInfo((object)($"[ValheimMCP] config: {Host}:{Port}, render {RenderMinSize}-{RenderMaxSize} " + $"(default {RenderDefaultSize}), allow={_allow.Count} deny={_deny.Count}")); } } catch (Exception ex) { ManualLogSource log3 = Plugin.Log; if (log3 != null) { log3.LogError((object)("[ValheimMCP] failed to load config, using defaults: " + ex.Message)); } } } public static int ClampRenderSize(int requested) { if (requested <= 0) { requested = RenderDefaultSize; } int val = Math.Max(64, RenderMinSize); return Math.Min(Math.Max(val, RenderMaxSize), Math.Max(val, requested)); } public static bool IsCommandAllowed(string commandLine, out string reason) { reason = null; string text = (commandLine ?? "").Trim(); int num = text.IndexOfAny(new char[2] { ' ', '\t' }); if (num >= 0) { text = text.Substring(0, num); } text = text.ToLowerInvariant(); if (Matches(_deny, text)) { reason = "command '" + text + "' is denied by config (commands.deny)"; return false; } if (_allow.Count > 0 && !Matches(_allow, text)) { reason = "command '" + text + "' is not in the config allowlist (commands.allow)"; return false; } return true; } private static bool Matches(List patterns, string name) { foreach (string pattern in patterns) { string text = pattern.ToLowerInvariant(); if (text.EndsWith("*")) { if (name.StartsWith(text.Substring(0, text.Length - 1))) { return true; } } else if (name == text) { return true; } } return false; } } [BepInPlugin("com.valheimmcp.server", "Valheim MCP Server", "0.1.0")] public class Plugin : BaseUnityPlugin { private Harmony _harmony; private HttpServer _server; public static ManualLogSource Log { get; private set; } private void Awake() { //IL_0016: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Expected O, but got Unknown Log = ((BaseUnityPlugin)this).Logger; ModConfig.Load(); _harmony = new Harmony("com.valheimmcp.server"); _harmony.PatchAll(typeof(ConsoleOutputCapture)); string text = $"http://{ModConfig.Host}:{ModConfig.Port}/"; try { _server = new HttpServer(text, ModConfig.CommandTimeoutMs); _server.Start(); Log.LogInfo((object)("Valheim MCP Server v0.1.0 listening on " + text)); } catch (Exception arg) { Log.LogError((object)string.Format("{0} failed to start on {1}: {2}", "Valheim MCP Server", text, arg)); } } private void Update() { MainThreadDispatcher.Pump(); } private void OnDestroy() { _server?.Stop(); Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } ManualLogSource log = Log; if (log != null) { log.LogInfo((object)"Valheim MCP Server stopped."); } } } internal static class PluginInfo { public const string Guid = "com.valheimmcp.server"; public const string Name = "Valheim MCP Server"; public const string Version = "0.1.0"; }