Skip to content
Merged
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
13 changes: 13 additions & 0 deletions plugins/outlook-classic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ Consequently direct stdio inside that boundary is not currently a supported
deployment. Do not disable isolation as an installation workaround. See the
[build report](../../docs/local-build.md) for test scope and the bridge follow-up.

The binary now also contains a parent-owned `--http-companion` transport for the
interactive Outlook user's session. Its parent supplies a 32-byte random bearer
token as a 43-character base64url line on a private stdin pipe and keeps that pipe
open. The helper binds one ephemeral `127.0.0.1` endpoint, reports its `/mcp` URL
on stdout, and stops when the pipe closes. It rejects incorrect Host or Origin
headers and missing or incorrect bearer credentials before MCP dispatch. The
build checks verify those denials and tool discovery without opening a mailbox.
Colossus Desktop does not yet launch or supervise this mode, provision its token,
or bind it to an installed plugin digest. The packaged `mcp.json` still declares
stdio, so this mode is not an AppContainer compatibility claim or a supported
one-step installation path. The managed lifecycle is tracked in
[Colossus issue #246](https://github.com/obscuritylabs/Colossus/issues/246).

Main-branch CI publishes signed OCI previews to GHCR and adds verified digests to
the catalog. The Windows executable is not Authenticode-signed. All 24 local live
checks passed on 2026-10-02 for alpha.4, including write tests confined to
Expand Down
109 changes: 109 additions & 0 deletions plugins/outlook-classic/src/HttpCompanion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;

namespace Colossus.OutlookClassic;

/// <summary>
/// A parent-owned, loopback-only MCP transport. Its bearer token arrives on stdin;
/// closing that inherited channel revokes the endpoint and shuts down the helper.
/// </summary>
internal static class HttpCompanion
{
internal static async Task<int> RunAsync(StaDispatcher dispatcher, OutlookReader outlook)
{
var token = await Console.In.ReadLineAsync();
if (token is null || !ValidToken(token))
{
Console.Error.WriteLine("Companion bootstrap token is missing or invalid.");
return 2;
}

var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
Args = [],
ContentRootPath = AppContext.BaseDirectory
});
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddSingleton(dispatcher);
builder.Services.AddSingleton(outlook);
builder.Services.AddMcpServer()
.WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless)
.WithTools<MailTools>();

await using var app = builder.Build();
var boundPort = 0;
app.Use(async (context, next) =>
{
if (!IPAddress.IsLoopback(context.Connection.RemoteIpAddress ?? IPAddress.None)
|| context.Request.Host.Host != "127.0.0.1"
|| context.Request.Host.Port != boundPort
|| context.Request.Headers.ContainsKey("Origin"))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
if (!Authorized(context.Request, token))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
});
app.MapMcp("/mcp");
await app.StartAsync();
var server = app.Services.GetRequiredService<IServer>();
var addresses = server.Features.Get<IServerAddressesFeature>()?.Addresses;
if (addresses is null || addresses.Count != 1 ||
!Uri.TryCreate(addresses.Single(), UriKind.Absolute, out var address) ||
address.Host != "127.0.0.1" || address.Port == 0)
{
await app.StopAsync();
Console.Error.WriteLine("Companion did not bind one IPv4 loopback endpoint.");
return 1;
}
boundPort = address.Port;
Console.WriteLine(JsonSerializer.Serialize(new
{
endpoint = $"http://127.0.0.1:{boundPort}/mcp",
processId = Environment.ProcessId
}));
await Console.Out.FlushAsync();

// The parent keeps stdin open for the lifetime of this exact process.
// EOF is a narrow and reliable revocation signal, including parent crashes.
await Console.In.ReadToEndAsync();
await app.StopAsync();
return 0;
}

private static bool ValidToken(string? token) =>
token is { Length: 43 } && token.All(character =>
character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_');

private static bool Authorized(HttpRequest request, string token)
{
if (request.Headers.Authorization.Count != 1) return false;
var header = request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return false;
var presented = header.AsSpan(7);
if (presented.Length != token.Length) return false;
var presentedBytes = Encoding.ASCII.GetBytes(presented.ToString());
var expectedBytes = Encoding.ASCII.GetBytes(token);
return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes);
}
}
3 changes: 2 additions & 1 deletion plugins/outlook-classic/src/OutlookClassicMcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
<PublishAot>false</PublishAot>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
</ItemGroup>
</Project>
9 changes: 7 additions & 2 deletions plugins/outlook-classic/src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ internal static class Program
{
public static async Task<int> Main(string[] args)
{
if (args is not ["--stdio"] and not ["--probe"] and not ["--version"])
if (args is not ["--stdio"] and not ["--probe"] and not ["--version"] and not ["--http-companion"])
{
Console.Error.WriteLine("Usage: outlook-classic-mcp --stdio | --probe | --version");
Console.Error.WriteLine("Usage: outlook-classic-mcp --stdio | --http-companion | --probe | --version");
return 2;
}
if (args[0] == "--version")
Expand All @@ -40,6 +40,11 @@ public static async Task<int> Main(string[] args)
}
}

if (args[0] == "--http-companion")
{
return await HttpCompanion.RunAsync(dispatcher, outlook);
}

// Do not load ambient appsettings or command-line configuration into this plugin.
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
Expand Down
Loading
Loading