Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of ValheimMCP v0.1.0
plugins/ValheimMCP/ValheimMCP.dll
Decompiled 11 hours agousing 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<Camera>(); ((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<string> Output = new List<string>(); } 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<KeyValuePair<string, string>> ListCommands() { List<KeyValuePair<string, string>> list = new List<KeyValuePair<string, string>>(); 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<string, string>(text, value2)); } } } return list.OrderBy<KeyValuePair<string, string>, string>((KeyValuePair<string, string> 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<string> _sink; public static void Begin() { _sink = new List<string>(); } public static List<string> End() { List<string> result = _sink ?? new List<string>(); _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<string> 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<KeyValuePair<string, string>> 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<Action> Queue = new ConcurrentQueue<Action>(); public static bool RunBlocking<T>(Func<T> 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<object> list) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append('['); bool flag = false; foreach (object item in list) { string text = HandleOne(item as Dictionary<string, object>, 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<string, object>, commandTimeoutMs); if (text2 != null) { return Json200(text2); } return Accepted(); } private static string HandleOne(Dictionary<string, object> 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<string, object> req) { string s = "2024-11-05"; if (req.TryGetValue("params", out var value) && value is Dictionary<string, object> 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<string, object> req, int commandTimeoutMs) { if (!req.TryGetValue("params", out var value) || !(value is Dictionary<string, object> dictionary)) { return ErrorResponse(id, -32602, "Invalid params"); } object value2; string text2 = (dictionary.TryGetValue("name", out value2) ? (value2 as string) : null); object value3; Dictionary<string, object> dictionary2 = (dictionary.TryGetValue("arguments", out value3) ? (value3 as Dictionary<string, object>) : 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<string, object> 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<string, object> ParseObject(string s, ref int i) { Dictionary<string, object> dictionary = new Dictionary<string, object>(); 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<object> ParseArray(string s, ref int i) { List<object> list = new List<object>(); 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<string, string> _scalars = new Dictionary<string, string>(); private readonly Dictionary<string, List<string>> _lists = new Dictionary<string, List<string>>(); 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<string>(); } } else if (text8.StartsWith("[") && text8.EndsWith("]")) { string text10 = text8.Substring(1, text8.Length - 2); List<string> list = new List<string>(); 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<string> GetList(string path) { if (!_lists.TryGetValue(path, out var value)) { return new List<string>(); } return value; } private static string StripComment(string line) { string text = line.TrimStart(Array.Empty<char>()); 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<string> _allow = new List<string>(); private static List<string> _deny = new List<string>(); 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<string> 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"; }