Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
759 changes: 759 additions & 0 deletions src/BattleTechMcpTools/BattleTechMcpTools.cs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/BattleTechMcpTools/BattleTechMcpTools.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>BattleTechMcpTools</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Spice86.Core\Spice86.Core.csproj" />
<ProjectReference Include="..\Spice86.Shared\Spice86.Shared.csproj" />
<PackageReference Include="ModelContextProtocol.Core" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
</ItemGroup>
</Project>
29 changes: 29 additions & 0 deletions src/BattleTechMcpTools/BattleTechOverrideSupplier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Spice86.Core.CLI;
using Spice86.Core.Emulator.Function;
using Spice86.Core.Emulator.Mcp;
using Spice86.Core.Emulator.VM;
using Spice86.Shared.Emulator.Memory;
using Spice86.Shared.Interfaces;
using System.Reflection;

namespace BattleTechMcpTools;

public class BattleTechOverrideSupplier : IOverrideSupplier, IMcpToolSupplier
{
public IDictionary<SegmentedAddress, FunctionInformation> GenerateFunctionInformations(
ILoggerService loggerService, Configuration configuration,
ushort programStartAddress, Machine machine)
{
return new Dictionary<SegmentedAddress, FunctionInformation>();
}

public IEnumerable<Assembly> GetMcpToolAssemblies()
{
return [typeof(BattleTechMcpTools).Assembly];
}

public IEnumerable<object> GetMcpServices()
{
return [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal readonly struct RegisterValueSet {
[0x11] = new VideoMode(new VgaMode(MemoryModel.Planar, 640, 480, 1, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet9, AttributeControllerRegisterValueSet8, GraphicsControllerRegisterValueSet5),
[0x12] = new VideoMode(new VgaMode(MemoryModel.Planar, 640, 480, 4, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet9, AttributeControllerRegisterValueSet7, GraphicsControllerRegisterValueSet5),
[0x13] = new VideoMode(new VgaMode(MemoryModel.Packed, 320, 200, 8, 8, 8, VgaConstants.GraphicsSegment), 0xFF, Palettes.Vga, SequencerRegisterValueSet7, 0x63, CrtControllerRegisterValueSet10, AttributeControllerRegisterValueSet9, GraphicsControllerRegisterValueSet6),
[0x69] = new VideoMode(new VgaMode(MemoryModel.Planar, 640, 480, 4, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet11, AttributeControllerRegisterValueSet7, GraphicsControllerRegisterValueSet5),
[0x6A] = new VideoMode(new VgaMode(MemoryModel.Planar, 800, 600, 4, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet11, AttributeControllerRegisterValueSet7, GraphicsControllerRegisterValueSet5)
};
}
41 changes: 30 additions & 11 deletions src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@
};
})
.WithHttpTransport(options => {
// Stateless mode: the server never issues Mcp-Session-Id headers.
// Stateful sessions cause 404 when AI clients reuse a session ID
// from a fresh TCP connection or skip the notifications/initialized
// handshake step, which is a common real-world pattern.
// Stateless mode keeps things simple — no session management,
// no Mcp-Session-Id headers, compatible with AI clients.
// The SDK does not map GET /mcp in stateless mode (POST only),
// but we add our own GET handler below to satisfy clients
// (like opencode remote MCP) that probe with GET first.
options.Stateless = true;
})
.WithToolsFromAssembly(typeof(EmulatorMcpTools).Assembly);
Expand All @@ -70,17 +71,27 @@
}
}

builder.WebHost.ConfigureKestrel(kestrel => {
kestrel.ListenLocalhost(port);
});

builder.WebHost.UseUrls($"http://localhost:{port}");
_app = builder.Build();
_app.MapGet("/health", () => Results.Json(new {
status = "ok",
service = "Spice86 MCP Server"
}));
_app.MapMcp("/mcp");

// The SDK doesn't map GET /mcp in stateless mode, but some MCP clients
// (including opencode remote MCP) probe with GET first. Return a minimal
// SSE endpoint event telling the client to POST to the same URL.
_app.MapGet("/mcp", async (HttpContext context) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache,no-store";
context.Response.Headers["X-Accel-Buffering"] = "no";
await context.Response.WriteAsync(
$"event: endpoint\ndata: /mcp/\n\n");
await context.Response.Body.FlushAsync();
});

_serverThread = new Thread(RunServerLoop) {
Name = "McpHttpHost",
IsBackground = true
Expand All @@ -95,12 +106,20 @@
}

try {
_app.Run();
_app.StartAsync().GetAwaiter().GetResult();
_loggerService.Information("MCP HTTP server is now listening");
// Block this thread indefinitely so the server keeps running.
// Using _app.Run() instead caused premature shutdown on background
// threads because ConsoleLifetime has no console to watch on a
// non-main thread.
} catch (ObjectDisposedException) {
// Host disposed while thread was exiting.
} catch (InvalidOperationException ex) {
_loggerService.Error(ex, "MCP HTTP server stopped unexpectedly");
} catch (Exception ex) {
_loggerService.Error(ex, "MCP HTTP server crashed");
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.

// Wait forever so the thread never exits.
new System.Threading.ManualResetEvent(false).WaitOne();

Check warning

Code scanning / CodeQL

Missing Dispose call on local IDisposable Warning

Disposable 'ManualResetEvent' is created but not disposed.
}

public void Dispose() {
Expand Down
4 changes: 2 additions & 2 deletions src/Spice86.Core/Emulator/OperatingSystem/DosDriveManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ public DosDriveManager(ILoggerService loggerService, string? cDriveFolderPath, s
cDriveFolderPath = DosPathResolver.GetExeParentFolder(executablePath);
}
cDriveFolderPath = ConvertUtils.ToSlashFolderPath(cDriveFolderPath);
_driveMap[GetDriveIndex('A')] = new VirtualDrive() { DriveLetter = 'A', MountedHostDirectory = "" };
_driveMap[GetDriveIndex('B')] = new VirtualDrive() { DriveLetter = 'B', MountedHostDirectory = "" };
_driveMap[GetDriveIndex('A')] = new VirtualDrive() { DriveLetter = 'A', MountedHostDirectory = cDriveFolderPath };
_driveMap[GetDriveIndex('B')] = new VirtualDrive() { DriveLetter = 'B', MountedHostDirectory = cDriveFolderPath };
var cDrive = new VirtualDrive { DriveLetter = 'C', MountedHostDirectory = cDriveFolderPath };
_driveMap[GetDriveIndex('C')] = cDrive;
CurrentDrive = cDrive;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public abstract class DosDriveBase {
/// <summary>
/// Gets the absolute path to the current DOS directory in use on the drive.
/// </summary>
public string CurrentDosDirectory { get; set; } = "";
public string CurrentDosDirectory { get; set; } = string.Empty;

/// <summary>
/// Gets if it is a network drive. Not supported, always <see langword="false" />
Expand Down
14 changes: 14 additions & 0 deletions src/Spice86.sln
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spice86.Storage.Fat", "Spic
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spice86.Storage.Cd", "Spice86.Storage.Cd\Spice86.Storage.Cd.csproj", "{058F9104-71C6-4544-A7C6-ADC0DD71FEF8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BattleTechMcpTools", "BattleTechMcpTools\BattleTechMcpTools.csproj", "{191D3DE1-DB65-4B39-BDF2-14C95A75144E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -147,6 +149,18 @@ Global
{058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x64.Build.0 = Release|Any CPU
{058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x86.ActiveCfg = Release|Any CPU
{058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x86.Build.0 = Release|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x64.ActiveCfg = Debug|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x64.Build.0 = Debug|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x86.ActiveCfg = Debug|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x86.Build.0 = Debug|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|Any CPU.Build.0 = Release|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x64.ActiveCfg = Release|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x64.Build.0 = Release|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x86.ActiveCfg = Release|Any CPU
{191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
Loading