using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Specialized; 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.Text.RegularExpressions; 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.2.0.0")] [assembly: AssemblyInformationalVersion("0.2.0+1966c462ed64ecc3aadb8d32adb981e2b6033030")] [assembly: AssemblyProduct("ValheimMCP")] [assembly: AssemblyTitle("ValheimMCP")] [assembly: AssemblyVersion("0.2.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 sealed class HttpMcpTransport : IMcpTransport { private readonly HttpListenerContext _ctx; private readonly object _lock = new object(); private bool _sse; private bool _closed; public bool AcceptsSse { get { string text = _ctx.Request.Headers["Accept"]; if (text != null) { return text.IndexOf("text/event-stream", StringComparison.OrdinalIgnoreCase) >= 0; } return false; } } public HttpMcpTransport(HttpListenerContext ctx) { _ctx = ctx; } public void WriteJson(int status, string body) { lock (_lock) { if (!_closed) { _closed = true; Write(_ctx, status, body); } } } public void WriteAccepted() { lock (_lock) { if (!_closed) { _closed = true; WriteEmpty(_ctx, 202); } } } public void BeginSse() { lock (_lock) { if (!_sse && !_closed) { _sse = true; _ctx.Response.StatusCode = 200; _ctx.Response.ContentType = "text/event-stream"; _ctx.Response.Headers["Cache-Control"] = "no-cache"; _ctx.Response.SendChunked = true; } } } public void SendSse(string jsonMessage) { lock (_lock) { if (!_sse || _closed) { return; } try { byte[] bytes = Encoding.UTF8.GetBytes("data: " + jsonMessage + "\n\n"); _ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); _ctx.Response.OutputStream.Flush(); } catch (Exception ex) { _closed = true; ManualLogSource log = Plugin.Log; if (log != null) { log.LogWarning((object)("[ValheimMCP] SSE write failed (client gone?): " + ex.Message)); } } } } public void EndSse() { lock (_lock) { if (!_sse || _closed) { return; } _closed = true; try { _ctx.Response.OutputStream.Close(); } catch { } } } } 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 ctx; try { ctx = _listener.GetContext(); } catch { if (!_running) { break; } continue; } ThreadPool.QueueUserWorkItem(delegate { try { Handle(ctx); } catch (Exception ex) { ManualLogSource log = Plugin.Log; if (log != null) { log.LogError((object)$"[ValheimMCP] request handler threw: {ex}"); } try { Write(ctx, 500, Json.Error(ex.Message)); } catch { } } }); } } private void Handle(HttpListenerContext ctx) { string text = ctx.Request.Url.AbsolutePath.TrimEnd(new char[1] { '/' }); string httpMethod = ctx.Request.HttpMethod; switch (text) { case "/mcp": if (httpMethod == "POST") { McpServer.Dispatch(ReadBody(ctx.Request), _commandTimeoutMs, new HttpMcpTransport(ctx)); } else if (httpMethod == "DELETE") { WriteEmpty(ctx, 200); } else { Write(ctx, 405, Json.Error("MCP endpoint supports POST (and DELETE); server-initiated GET stream not implemented")); } 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 (text == "/command" && httpMethod == "POST") { string text2 = ReadCommandText(ctx.Request); CommandResult result3; Exception error3; if (string.IsNullOrWhiteSpace(text2)) { Write(ctx, 400, Json.Error("missing command text (send as raw body or ?text=)")); } else if (!MainThreadDispatcher.RunBlocking(() => ConsoleBridge.Run(text2), _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(text2, result3)); } } else if (text == "/log" && httpMethod == "GET") { NameValueCollection queryString = ctx.Request.QueryString; long result4; long since = (long.TryParse(queryString["since"], out result4) ? result4 : (-1)); int.TryParse(queryString["maxLines"], out var result5); int maxLines = ModConfig.ClampLogLines(result5); string text3 = queryString["contains"]; bool flag = queryString["regex"] == "1" || string.Equals(queryString["regex"], "true", StringComparison.OrdinalIgnoreCase); Func filter = null; if (!string.IsNullOrEmpty(text3)) { if (flag) { Regex rx; try { rx = new Regex(text3); } catch (Exception ex) { Write(ctx, 400, Json.Error("invalid regex: " + ex.Message)); return; } filter = (string line) => rx.IsMatch(line); } else { string needle = text3; filter = (string line) => line.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; } } long cursor; int totalMatching; int dropped; List log = LogWatch.GetLog(since, maxLines, filter, out cursor, out totalMatching, out dropped); Write(ctx, 200, Json.LogResult(cursor, totalMatching, dropped, log)); } else { Write(ctx, 404, Json.Error("unknown route: " + httpMethod + " " + text)); } } 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 LogResult(long cursor, int matching, int dropped, IEnumerable lines) { return "{\"ok\":true,\"cursor\":" + cursor + ",\"matching\":" + matching + ",\"dropped\":" + dropped + ",\"lines\":" + Array(lines) + "}"; } 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 LogWatch { private sealed class Waiter { public Func Match; public Action OnLine; public readonly ManualResetEventSlim Done = new ManualResetEventSlim(initialState: false); public volatile string MatchedLine; } private struct Rec { public long Seq; public string Line; } private sealed class Listener : ILogListener, IDisposable { public void LogEvent(object sender, LogEventArgs eventArgs) { //IL_0026: Unknown result type (might be due to invalid IL or missing references) if (eventArgs != null) { ILogSource source = eventArgs.Source; string arg = ((source != null) ? source.SourceName : null) ?? "?"; string line = $"[{eventArgs.Level}:{arg}] {eventArgs.Data}"; Buffer(line); Dispatch(line); } } public void Dispose() { } } private static readonly object Gate = new object(); private static readonly List Waiters = new List(); private static Listener _listener; private static readonly object BufGate = new object(); private static Rec[] _ring; private static int _ringHead; private static int _ringCount; private static long _ringSeq; public static void Install() { if (_listener != null) { return; } lock (BufGate) { if (_ring == null) { _ring = new Rec[Math.Max(16, ModConfig.LogBufferCapacity)]; } } _listener = new Listener(); Logger.Listeners.Add((ILogListener)(object)_listener); } public static void Uninstall() { if (_listener != null) { try { Logger.Listeners.Remove((ILogListener)(object)_listener); } catch { } _listener.Dispose(); _listener = null; } lock (Gate) { foreach (Waiter waiter in Waiters) { waiter.Done.Set(); } Waiters.Clear(); } } public static string Wait(Func match, int timeoutMs, Action onLine, out bool timedOut) { Waiter waiter = new Waiter { Match = match, OnLine = onLine }; lock (Gate) { Waiters.Add(waiter); } try { timedOut = !waiter.Done.Wait(timeoutMs); return timedOut ? null : waiter.MatchedLine; } finally { lock (Gate) { Waiters.Remove(waiter); } waiter.Done.Dispose(); } } private static void Dispatch(string line) { Waiter[] array; lock (Gate) { if (Waiters.Count == 0) { return; } array = Waiters.ToArray(); } Waiter[] array2 = array; foreach (Waiter waiter in array2) { if (waiter.Done.IsSet) { continue; } if (waiter.OnLine != null) { try { waiter.OnLine(line); } catch (Exception ex) { ManualLogSource log = Plugin.Log; if (log != null) { log.LogWarning((object)("[ValheimMCP] log-watch onLine threw: " + ex.Message)); } } } bool flag; try { flag = waiter.Match(line); } catch (Exception ex2) { ManualLogSource log2 = Plugin.Log; if (log2 != null) { log2.LogWarning((object)("[ValheimMCP] log-watch match threw: " + ex2.Message)); } continue; } if (flag) { waiter.MatchedLine = line; waiter.Done.Set(); } } } private static void Buffer(string line) { lock (BufGate) { if (_ring != null) { _ringSeq++; _ring[_ringHead] = new Rec { Seq = _ringSeq, Line = line }; _ringHead = (_ringHead + 1) % _ring.Length; if (_ringCount < _ring.Length) { _ringCount++; } } } } public static List GetLog(long since, int maxLines, Func filter, out long cursor, out int totalMatching, out int dropped) { List list = new List(); totalMatching = 0; dropped = 0; lock (BufGate) { cursor = _ringSeq; if (_ring == null || _ringCount == 0) { return list; } long num = _ringSeq - _ringCount + 1; if (since >= 0 && since + 1 < num) { dropped = (int)(num - 1 - since); } int num2 = (_ringHead - _ringCount + _ring.Length) % _ring.Length; List list2 = new List(); for (int i = 0; i < _ringCount; i++) { Rec rec = _ring[(num2 + i) % _ring.Length]; if (rec.Seq > since && (filter == null || filter(rec.Line))) { list2.Add(rec.Line); } } totalMatching = list2.Count; for (int j = ((maxLines > 0 && list2.Count > maxLines) ? (list2.Count - maxLines) : 0); j < list2.Count; j++) { list.Add(list2[j]); } return list; } } } 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 interface IMcpTransport { bool AcceptsSse { get; } void WriteJson(int status, string body); void WriteAccepted(); void BeginSse(); void SendSse(string jsonMessage); void EndSse(); } internal static class McpServer { public const string ServerName = "valheim-mcp"; public const string ServerVersion = "0.2.0"; private const string DefaultProtocol = "2024-11-05"; public static void Dispatch(string body, int commandTimeoutMs, IMcpTransport tx) { object obj; try { obj = MiniJson.Parse(body); } catch (Exception ex) { tx.WriteJson(200, ErrorResponse(null, -32700, "Parse error: " + ex.Message)); return; } 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) { tx.WriteJson(200, stringBuilder.ToString()); } else { tx.WriteAccepted(); } return; } Dictionary req = obj as Dictionary; if (tx.AcceptsSse && IsWaitForLogCall(req, out var id, out var args, out var progressToken) && progressToken != null) { StreamWaitForLog(id, args, progressToken, tx); return; } string text2 = HandleOne(req, commandTimeoutMs); if (text2 == null) { tx.WriteAccepted(); } else { tx.WriteJson(200, text2); } } 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 { Length: >0 } text) { 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.2.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(','); AppendTool(stringBuilder, "wait_for_log", "Block until a line matching 'pattern' appears in the BepInEx/Valheim log (from the MCP server, the game, or any other mod), then return that line. Use this to wait for an asynchronous event — e.g. a mod hot-reload completing — instead of sleeping or polling: it returns the moment the line appears, or reports a timeout. Each log line is matched in the form [Level:Source] message. When the request carries a progressToken, observed log lines stream back as progress notifications while waiting.", "{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\",\"description\":\"Text to wait for, matched against each formatted log line (case-insensitive substring by default).\"},\"regex\":{\"type\":\"boolean\",\"description\":\"Treat 'pattern' as a .NET regular expression instead of a substring (default false).\"},\"timeoutMs\":{\"type\":\"number\",\"description\":\"Maximum time to wait in milliseconds. Clamped to the server's configured maximum (default 120000).\"}},\"required\":[\"pattern\"]}"); stringBuilder.Append(','); AppendTool(stringBuilder, "get_log", "Fetch recent lines from the in-memory BepInEx/Valheim log buffer (the MCP server, the game, and every other mod) — i.e. tail the log on demand, including on a dedicated server whose log file isn't directly accessible. Omit 'since' to get the most recent lines; pass the 'cursor' value from a previous call as 'since' to get only what's new since then (incremental polling). Optionally filter with 'contains' (case-insensitive substring) or 'regex'. The first output line is a header: 'cursor= returned= matching= dropped=' — pass that cursor back as 'since' next time; dropped>0 means older lines were evicted from the buffer between calls (a gap). Unlike wait_for_log (which blocks for a pattern), this returns immediately with whatever is buffered.", "{\"type\":\"object\",\"properties\":{\"since\":{\"type\":\"number\",\"description\":\"Return only lines after this cursor (from a prior get_log call). Omit for the most recent lines.\"},\"maxLines\":{\"type\":\"number\",\"description\":\"Max lines to return (default 200, clamped to the server's configured maximum).\"},\"contains\":{\"type\":\"string\",\"description\":\"Only return lines containing this text (case-insensitive substring).\"},\"regex\":{\"type\":\"boolean\",\"description\":\"Treat 'contains' as a .NET regular expression instead of a substring (default false).\"}}}"); 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 text = (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(text)) { return ErrorResponse(id, -32602, "Missing tool name"); } switch (text) { 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 text2 = ((dictionary2 != null && dictionary2.TryGetValue("text", out value7)) ? (value7 as string) : null); if (string.IsNullOrWhiteSpace(text2)) { return Result(id, ToolText("missing 'text' argument", isError: true)); } if (!MainThreadDispatcher.RunBlocking(() => ConsoleBridge.Run(text2), 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) : ((float?)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")); } case "wait_for_log": return Result(id, DoWaitForLog(dictionary2, null)); case "get_log": return Result(id, DoGetLog(dictionary2)); default: return ErrorResponse(id, -32602, "Unknown tool: " + text); } } 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 bool IsWaitForLogCall(Dictionary req, out object id, out Dictionary args, out object progressToken) { id = null; args = null; progressToken = null; if (req == null) { return false; } if (!req.TryGetValue("method", out var value) || !(value as string == "tools/call")) { return false; } if (!req.TryGetValue("id", out id)) { return false; } if (!req.TryGetValue("params", out var value2) || !(value2 is Dictionary dictionary)) { return false; } if ((dictionary.TryGetValue("name", out var value3) ? (value3 as string) : null) != "wait_for_log") { return false; } args = (dictionary.TryGetValue("arguments", out var value4) ? (value4 as Dictionary) : null); if (dictionary.TryGetValue("_meta", out var value5) && value5 is Dictionary dictionary2 && dictionary2.TryGetValue("progressToken", out var value6)) { progressToken = value6; } return true; } private static string DoWaitForLog(Dictionary args, Action onLine) { object value; string pattern = ((args != null && args.TryGetValue("pattern", out value)) ? (value as string) : null); if (string.IsNullOrEmpty(pattern)) { return ToolText("missing 'pattern' argument", isError: true); } bool flag = default(bool); int num; if (args.TryGetValue("regex", out var value2)) { if (value2 is bool) { flag = (bool)value2; num = 1; } else { num = 0; } } else { num = 0; } int num2 = num & (flag ? 1 : 0); int num3 = ModConfig.ClampWaitTimeout((int)Num(args, "timeoutMs", ModConfig.WaitDefaultTimeoutMs)); Func match; if (num2 != 0) { Regex rx; try { rx = new Regex(pattern); } catch (Exception ex) { return ToolText("invalid regex: " + ex.Message, isError: true); } match = (string line) => rx.IsMatch(line); } else { match = (string line) => line.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0; } bool timedOut; string text = LogWatch.Wait(match, num3, onLine, out timedOut); if (timedOut) { return ToolText($"timed out after {num3}ms waiting for log match: {pattern}", isError: true); } return ToolText("matched: " + text, isError: false); } private static string DoGetLog(Dictionary args) { long since = (long)Num(args, "since", -1.0); int maxLines = ModConfig.ClampLogLines((int)Num(args, "maxLines", ModConfig.LogDefaultLines)); object value; string text = ((args != null && args.TryGetValue("contains", out value)) ? (value as string) : null); bool flag = default(bool); int num; if (args != null && args.TryGetValue("regex", out var value2)) { if (value2 is bool) { flag = (bool)value2; num = 1; } else { num = 0; } } else { num = 0; } bool flag2 = (byte)((uint)num & (flag ? 1u : 0u)) != 0; Func filter = null; if (!string.IsNullOrEmpty(text)) { if (flag2) { Regex rx; try { rx = new Regex(text); } catch (Exception ex) { return ToolText("invalid regex: " + ex.Message, isError: true); } filter = (string line) => rx.IsMatch(line); } else { string needle = text; filter = (string line) => line.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; } } long cursor; int totalMatching; int dropped; List log = LogWatch.GetLog(since, maxLines, filter, out cursor, out totalMatching, out dropped); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("cursor=").Append(cursor.ToString(CultureInfo.InvariantCulture)).Append(" returned=") .Append(log.Count.ToString(CultureInfo.InvariantCulture)) .Append(" matching=") .Append(totalMatching.ToString(CultureInfo.InvariantCulture)) .Append(" dropped=") .Append(dropped.ToString(CultureInfo.InvariantCulture)) .Append('\n'); stringBuilder.Append((log.Count > 0) ? string.Join("\n", log) : "(no matching log lines)"); return ToolText(stringBuilder.ToString(), isError: false); } private static void StreamWaitForLog(object id, Dictionary args, object progressToken, IMcpTransport tx) { tx.BeginSse(); int counter = 0; Timer timer = null; try { timer = new Timer(delegate { try { Emit("waiting…"); } catch { } }, null, ModConfig.WaitHeartbeatMs, ModConfig.WaitHeartbeatMs); string resultJson = DoWaitForLog(args, delegate(string line) { try { Emit(line); } catch { } }); timer.Dispose(); timer = null; tx.SendSse(Result(id, resultJson)); } catch (Exception ex) { tx.SendSse(Result(id, ToolText("wait_for_log threw: " + ex.Message, isError: true))); } finally { timer?.Dispose(); tx.EndSse(); } void Emit(string message) { int progress = Interlocked.Increment(ref counter); tx.SendSse(ProgressNotification(progressToken, progress, message)); } } private static string ProgressNotification(object progressToken, int progress, string message) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{"); stringBuilder.Append("\"progressToken\":").Append(FormatId(progressToken)); stringBuilder.Append(",\"progress\":").Append(progress.ToString(CultureInfo.InvariantCulture)); if (message != null) { stringBuilder.Append(",\"message\":").Append(Json.Str(message)); } stringBuilder.Append("}}"); return stringBuilder.ToString(); } 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) && Math.Abs(num - Math.Floor(num)) < double.Epsilon && Math.Abs(num) < 9.2E+18) { return ((long)num).ToString(CultureInfo.InvariantCulture); } return num.ToString("R", CultureInfo.InvariantCulture); } return Json.Str(id.ToString()); } } 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; public static int WaitDefaultTimeoutMs = 120000; public static int WaitMaxTimeoutMs = 600000; public static int WaitHeartbeatMs = 5000; public static int LogBufferCapacity = 2000; public static int LogDefaultLines = 200; public static int LogMaxLines = 1000; 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\nwait:\n defaultTimeoutMs: 120000 # wait_for_log timeout when 'timeoutMs' is omitted\n maxTimeoutMs: 600000 # hard cap on any wait_for_log request\n heartbeatMs: 5000 # SSE progress heartbeat interval while a wait is pending\n\nlog:\n bufferCapacity: 2000 # in-memory ring of recent log lines retained for get_log\n defaultLines: 200 # get_log lines returned when 'maxLines' is omitted\n maxLines: 1000 # hard cap on lines per get_log request\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\nwait:\n defaultTimeoutMs: 120000 # wait_for_log timeout when 'timeoutMs' is omitted\n maxTimeoutMs: 600000 # hard cap on any wait_for_log request\n heartbeatMs: 5000 # SSE progress heartbeat interval while a wait is pending\n\nlog:\n bufferCapacity: 2000 # in-memory ring of recent log lines retained for get_log\n defaultLines: 200 # get_log lines returned when 'maxLines' is omitted\n maxLines: 1000 # hard cap on lines per get_log request\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); WaitDefaultTimeoutMs = miniYaml.GetInt("wait.defaultTimeoutMs", WaitDefaultTimeoutMs); WaitMaxTimeoutMs = miniYaml.GetInt("wait.maxTimeoutMs", WaitMaxTimeoutMs); WaitHeartbeatMs = miniYaml.GetInt("wait.heartbeatMs", WaitHeartbeatMs); LogBufferCapacity = miniYaml.GetInt("log.bufferCapacity", LogBufferCapacity); LogDefaultLines = miniYaml.GetInt("log.defaultLines", LogDefaultLines); LogMaxLines = miniYaml.GetInt("log.maxLines", LogMaxLines); _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 int ClampWaitTimeout(int requested) { if (requested <= 0) { requested = WaitDefaultTimeoutMs; } return Math.Min(WaitMaxTimeoutMs, Math.Max(1000, requested)); } public static int ClampLogLines(int requested) { if (requested <= 0) { requested = LogDefaultLines; } return Math.Min(Math.Max(1, Math.Min(LogMaxLines, LogBufferCapacity)), Math.Max(1, 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.2.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)); LogWatch.Install(); string text = $"http://{ModConfig.Host}:{ModConfig.Port}/"; try { _server = new HttpServer(text, ModConfig.CommandTimeoutMs); _server.Start(); Log.LogInfo((object)("Valheim MCP Server v0.2.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(); LogWatch.Uninstall(); 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.2.0"; }