diff --git a/MtaDebugCompanionMcp.Common/Clients/MtaServerDebugClient.cs b/MtaDebugCompanionMcp.Common/Clients/MtaServerDebugClient.cs new file mode 100644 index 0000000..b4ce49a --- /dev/null +++ b/MtaDebugCompanionMcp.Common/Clients/MtaServerDebugClient.cs @@ -0,0 +1,41 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace MtaDebugCompanionMcp.Common.Clients; + +public class MtaServerDebugClient(HttpClient httpClient) +{ + private static StringContent ToJsonContent(T value) => + new(JsonSerializer.Serialize(value), Encoding.UTF8, "application/json"); + + public async Task RunCode(string code) + { + var response = await httpClient.PostAsync("/debugCompanion/call/httpRun", ToJsonContent(new[] { code })); + return await response.Content.ReadAsStringAsync(); + } + + public async Task RestartResource(string name) + { + var response = await httpClient.PostAsync("/debugCompanion/call/httpRestartResource", ToJsonContent(new[] { name })); + return await response.Content.ReadAsStringAsync(); + } + + public async Task StartResource(string name) + { + var response = await httpClient.PostAsync("/debugCompanion/call/httpStartResource", ToJsonContent(new[] { name })); + return await response.Content.ReadAsStringAsync(); + } + + public async Task StopResource(string name) + { + var response = await httpClient.PostAsync("/debugCompanion/call/httpStopResource", ToJsonContent(new[] { name })); + return await response.Content.ReadAsStringAsync(); + } + + public async Task GetLogs() + { + var response = await httpClient.PostAsync("/debugCompanion/call/httpGetDebugLog", ToJsonContent(Array.Empty())); + return await response.Content.ReadAsStringAsync(); + } +} diff --git a/MtaDebugCompanionMcp.Common/MtaDebugCompanionMcp.Common.csproj b/MtaDebugCompanionMcp.Common/MtaDebugCompanionMcp.Common.csproj new file mode 100644 index 0000000..e7aaae6 --- /dev/null +++ b/MtaDebugCompanionMcp.Common/MtaDebugCompanionMcp.Common.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/MtaDebugCompanionMcp.Common/MtaDebugCompanionMcpServiceCollectionExtensions.cs b/MtaDebugCompanionMcp.Common/MtaDebugCompanionMcpServiceCollectionExtensions.cs new file mode 100644 index 0000000..142132d --- /dev/null +++ b/MtaDebugCompanionMcp.Common/MtaDebugCompanionMcpServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using MtaDebugCompanionMcp.Common.Clients; + +namespace MtaDebugCompanionMcp.Common; + +public static class MtaDebugCompanionMcpServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Adds the services required for the MTA debug companion MCP. + /// + /// + public IServiceCollection AddMtaDebugCompanionMcpServices(string? baseAddress, string? apiKey) + { + services.AddHttpClient(x => + { + x.BaseAddress = new Uri(baseAddress ?? "http://localhost:22005"); + x.DefaultRequestHeaders.Add("api-key", apiKey ?? "default"); + }); + + return services; + } + } +} \ No newline at end of file diff --git a/MtaDebugCompanionMcp.Common/Tools/MtaDebugCompanionTools.cs b/MtaDebugCompanionMcp.Common/Tools/MtaDebugCompanionTools.cs new file mode 100644 index 0000000..83bb9fb --- /dev/null +++ b/MtaDebugCompanionMcp.Common/Tools/MtaDebugCompanionTools.cs @@ -0,0 +1,62 @@ +using ModelContextProtocol.Server; +using MtaDebugCompanionMcp.Common.Clients; +using System.ComponentModel; + +namespace MtaDebugCompanionMcp.Common.Tools; + +public enum ScriptSide { Server, Client, Shared } + +/// +/// This class contains the tools that are exposed to the AI agent for accessing information from the MTA wiki +/// +/// +[McpServerToolType] +[Description("Tools that allow access certain actions on the MTA server")] +public class MtaDebugCompanionTools(MtaServerDebugClient mtaServer) +{ + private const string MtaLogoUrl = "https://wiki.multitheftauto.com/images/thumb/5/58/Mtalogo.png/100px-Mtalogo.png"; + + [McpServerTool(Name = nameof(RunCode), IconSource = MtaLogoUrl)] + [Description(""" + Runs any arbitrary Lua code on the MTA server, for the purpose of debugging. Will return any result from the code that was run. + If running more than a single statement returned information can be obtained using: + (function() + -- code here + return toJSON({ --[[ any value here ]] }) + end)() + """)] + public Task RunCode(string code) + { + return mtaServer.RunCode(code); + } + + [McpServerTool(Name = nameof(RestartResource), IconSource = MtaLogoUrl)] + [Description("Restarts the specified resource on the MTA server.")] + public Task RestartResource(string name) + { + return mtaServer.RestartResource(name); + } + + [McpServerTool(Name = nameof(StartResource), IconSource = MtaLogoUrl)] + [Description("Starts the specified resource on the MTA server.")] + public Task StartResource(string name) + { + return mtaServer.StartResource(name); + } + + [McpServerTool(Name = nameof(StopResource), IconSource = MtaLogoUrl)] + [Description("Stops the specified resource on the MTA server.")] + public Task StopResource(string name) + { + return mtaServer.StopResource(name); + } + + [McpServerTool(Name = nameof(GetLogs), IconSource = MtaLogoUrl)] + [Description("Retrieves the latest 100 lines of debug logs.")] + public Task GetLogs() + { + return mtaServer.GetLogs(); + } + + +} diff --git a/MtaDebugCompanionMcp.Http/MtaDebugCompanionMcp.Http.csproj b/MtaDebugCompanionMcp.Http/MtaDebugCompanionMcp.Http.csproj new file mode 100644 index 0000000..b94702e --- /dev/null +++ b/MtaDebugCompanionMcp.Http/MtaDebugCompanionMcp.Http.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/MtaDebugCompanionMcp.Http/Program.cs b/MtaDebugCompanionMcp.Http/Program.cs new file mode 100644 index 0000000..641ea56 --- /dev/null +++ b/MtaDebugCompanionMcp.Http/Program.cs @@ -0,0 +1,21 @@ +using MtaDebugCompanionMcp.Common; +using MtaDebugCompanionMcp.Common.Tools; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true); + +builder.Services.AddMtaDebugCompanionMcpServices(builder.Configuration.GetValue("serverHost"), builder.Configuration.GetValue("apiKey")); + +builder.Services + .AddMcpServer() + .WithToolsFromAssembly(typeof(MtaDebugCompanionTools).Assembly) + .WithHttpTransport(x => + { + x.Stateless = true; + }); + +var app = builder.Build(); + +app.MapMcp("/"); +app.Run(); diff --git a/MtaDebugCompanionMcp.Http/Properties/launchSettings.json b/MtaDebugCompanionMcp.Http/Properties/launchSettings.json new file mode 100644 index 0000000..3ac88d0 --- /dev/null +++ b/MtaDebugCompanionMcp.Http/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5277", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7117;http://localhost:5277", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/MtaDebugCompanionMcp.Http/appsettings.Development.json b/MtaDebugCompanionMcp.Http/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/MtaDebugCompanionMcp.Http/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/MtaDebugCompanionMcp.Http/appsettings.json b/MtaDebugCompanionMcp.Http/appsettings.json new file mode 100644 index 0000000..47ae76c --- /dev/null +++ b/MtaDebugCompanionMcp.Http/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + + "serverHost": "http://localhost:22005", + "apiKey": "default" +} diff --git a/MtaMcpServer.slnx b/MtaMcpServer.slnx index 5be5eb7..0f28d2e 100644 --- a/MtaMcpServer.slnx +++ b/MtaMcpServer.slnx @@ -1,5 +1,11 @@ - - - + + + + + + + + + diff --git a/Resources/debugCompanion/debug.http b/Resources/debugCompanion/debug.http new file mode 100644 index 0000000..53f8fee --- /dev/null +++ b/Resources/debugCompanion/debug.http @@ -0,0 +1,24 @@ +POST http://localhost:22005/debugCompanion/call/httpRun +api-key: default + +[ + "5 + 5" +] + +### +POST http://localhost:22005/debugCompanion/call/httpRun +api-key: default + +[ + "(function() error(\"INTENTIONAL_TEST_ERROR_FROM_RUNCODE\") end)()" +] + + +### +POST http://localhost:22005/debugCompanion/call/httpRestartResource +api-key: default + +[ + "debugCompanion" +] + diff --git a/Resources/debugCompanion/exports/debugLog.lua b/Resources/debugCompanion/exports/debugLog.lua new file mode 100644 index 0000000..1e29f59 --- /dev/null +++ b/Resources/debugCompanion/exports/debugLog.lua @@ -0,0 +1,35 @@ +local debugLogCache = {} +local MAX_DEBUG_LOG = 100 + +local function addToDebugCache(entry) + table.insert(debugLogCache, entry) + if #debugLogCache > MAX_DEBUG_LOG then + table.remove(debugLogCache, 1) + end +end + +local function onDebugMessageHandler(debugMessage, debugLevel, debugFile, debugLine, debugRed, debugGreen, debugBlue) + local entry = { + message = tostring(debugMessage), + level = tonumber(debugLevel) or 0, + file = debugFile or false, + line = debugLine or false, + color = { debugRed or 255, debugGreen or 255, debugBlue or 255 }, + time = os.time() + } + + addToDebugCache(entry) + + return false +end + +addEventHandler("onDebugMessage", root, onDebugMessageHandler) + +function httpGetDebugLog() + if not verifyApiKey() then + return "Unauthorised" + end + + return toJSON(debugLogCache) +end + diff --git a/Resources/debugCompanion/exports/resources.lua b/Resources/debugCompanion/exports/resources.lua new file mode 100644 index 0000000..2769109 --- /dev/null +++ b/Resources/debugCompanion/exports/resources.lua @@ -0,0 +1,23 @@ +function httpStartResource(name) + if not verifyApiKey() then + return "Unauthorised" + end + + return toJSON({ result = startResource(getResourceFromName(name)) }) +end + +function httpRestartResource(name) + if not verifyApiKey() then + return "Unauthorised" + end + + return toJSON({ result = restartResource(getResourceFromName(name)) }) +end + +function httpStopResource(name) + if not verifyApiKey() then + return "Unauthorised" + end + + return toJSON({ result = stopResource(getResourceFromName(name)) }) +end diff --git a/Resources/debugCompanion/exports/runcode.lua b/Resources/debugCompanion/exports/runcode.lua new file mode 100644 index 0000000..8d0082c --- /dev/null +++ b/Resources/debugCompanion/exports/runcode.lua @@ -0,0 +1,42 @@ +function httpRun(code) + if not verifyApiKey() then + return "Unauthorised" + end + + local notReturned + local commandFunction, errorMsg = loadstring("return " .. code) + if errorMsg then + notReturned = true + commandFunction, errorMsg = loadstring(code) + end + + if errorMsg then + return "Error: " .. errorMsg + end + + local results = { pcall(commandFunction) } + if not results[1] then + return "Error: " .. results[2] + end + + if not notReturned then + local resultsString = "" + local first = true + for i = 2, #results do + if first then + first = false + else + resultsString = resultsString .. ", " + end + local resultType = type(results[i]) + if isElement(results[i]) then + resultType = "element:" .. getElementType(results[i]) + end + resultsString = resultsString .. tostring(results[i]) .. " [" .. resultType .. "]" + end + + return "Command results: " .. resultsString + end + + return "Command executed!" +end \ No newline at end of file diff --git a/Resources/debugCompanion/helpers/apiHelpers.lua b/Resources/debugCompanion/helpers/apiHelpers.lua new file mode 100644 index 0000000..c5d9a13 --- /dev/null +++ b/Resources/debugCompanion/helpers/apiHelpers.lua @@ -0,0 +1,48 @@ +local apiKey + +function verifyApiKey() + if not apiKey or not requestHeaders then + return false + end + + local header = requestHeaders["api-key"] + + return header == apiKey +end + +function loadApiKey() + local setting = get("@apiKey") + + if setting then + apiKey = setting + + if apiKey == "default" then + outputServerLog("Default API key detected, you can change it with /generatekey") + addCommandHandler("generatekey", generateApiKey, false, false) + end + end +end + +function generateApiKey() + local setting = get("@apiKey") + + if setting == "default" then + local key = generateRandomString(32) + set("@apiKey", key) + apiKey = key + + outputServerLog("A new key was generated: " .. key) + end +end + +function generateRandomString(length) + local charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + local randomString = "" + for i = 1, length do + local randomIndex = math.random(1, #charset) + randomString = randomString .. charset:sub(randomIndex, randomIndex) + end + return randomString +end + +loadApiKey() diff --git a/Resources/debugCompanion/meta.xml b/Resources/debugCompanion/meta.xml new file mode 100644 index 0000000..20a6ace --- /dev/null +++ b/Resources/debugCompanion/meta.xml @@ -0,0 +1,27 @@ + +