Skip to content

Commit 921c1e7

Browse files
committed
Add Streamable HTTP transport for the MCP server
Serve the MCP tool set over Streamable HTTP for concurrent remote clients, alongside the existing stdio transport. Adds the --http, --host, --port, and --token flags to `motus mcp`; with no --http the stdio behavior is unchanged. The HTTP host lives in a new non-AOT Motus.Mcp.Http project (Sdk.Web, ModelContextProtocol.AspNetCore) so the AspNetCore dependency stays out of the AOT-clean Motus.Mcp. It reuses the same tool registration via a shared McpServerConfiguration helper, so both transports advertise an identical tool set and server identity. Per-client session isolation: each connected client gets its own browser, contexts, and tabs. The SDK has no per-session DI scope, so a singleton McpSessionRegistry keyed by the MCP session id holds one McpSessionBundle per client, created and disposed via the transport's RunSessionHandler. PerSessionExecutionContext flows an AsyncLocal bundle into the per-session tool service factories, leaving all tool methods unchanged. ActivePageService is no longer IAsyncDisposable (its cleanup moves to an internal Shutdown the bundle calls) so the per-call DI scope cannot tear down a shared per-session instance. Security: binds loopback by default, guards the endpoint with a constant-time bearer-token check when a token is set, and refuses to bind a non-loopback host without one. Idle sessions (and their browsers) are reaped after 30 minutes. Tests: HTTP handshake and tool advertisement, the per-session service resolution path, bearer-token accept/reject, the non-loopback guard, and an integration test proving two concurrent clients stay isolated and their browsers are torn down on disconnect.
1 parent 11faee6 commit 921c1e7

21 files changed

Lines changed: 806 additions & 56 deletions

Motus.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motus.Mcp", "src\Motus.Mcp\
4949
EndProject
5050
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motus.Mcp.Tests", "tests\Motus.Mcp.Tests\Motus.Mcp.Tests.csproj", "{110037A6-2F59-4273-B115-5047BDC39CE6}"
5151
EndProject
52+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motus.Mcp.Http", "src\Motus.Mcp.Http\Motus.Mcp.Http.csproj", "{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}"
53+
EndProject
5254
Global
5355
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5456
Debug|Any CPU = Debug|Any CPU
@@ -299,6 +301,18 @@ Global
299301
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x64.Build.0 = Release|Any CPU
300302
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x86.ActiveCfg = Release|Any CPU
301303
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x86.Build.0 = Release|Any CPU
304+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
305+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
306+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Debug|x64.ActiveCfg = Debug|Any CPU
307+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Debug|x64.Build.0 = Debug|Any CPU
308+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Debug|x86.ActiveCfg = Debug|Any CPU
309+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Debug|x86.Build.0 = Debug|Any CPU
310+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
311+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Release|Any CPU.Build.0 = Release|Any CPU
312+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Release|x64.ActiveCfg = Release|Any CPU
313+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Release|x64.Build.0 = Release|Any CPU
314+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Release|x86.ActiveCfg = Release|Any CPU
315+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA}.Release|x86.Build.0 = Release|Any CPU
302316
EndGlobalSection
303317
GlobalSection(SolutionProperties) = preSolution
304318
HideSolutionNode = FALSE
@@ -324,6 +338,7 @@ Global
324338
{6B1A49AD-10DC-477A-81A3-255F95F4C253} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
325339
{2B966099-CDAA-41B3-9419-2CAE298A1C68} = {C2BE1865-1543-4EBB-9E95-92EE083BF967}
326340
{110037A6-2F59-4273-B115-5047BDC39CE6} = {47F5729D-17CB-4046-88FA-BB0F9F660B6E}
341+
{F2B42529-6278-4AC5-B1E4-49E55A95FDEA} = {C2BE1865-1543-4EBB-9E95-92EE083BF967}
327342
EndGlobalSection
328343
GlobalSection(ExtensibilityGlobals) = postSolution
329344
SolutionGuid = {3A99AF87-BFA9-49CF-A20A-71753BE54A53}

src/Motus.Cli/Commands/McpCommand.cs

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
using System.CommandLine;
22
using Motus.Mcp;
3+
using Motus.Mcp.Http;
34

45
namespace Motus.Cli.Commands;
56

67
public static class McpCommand
78
{
9+
private const string TokenEnvVar = "MOTUS_MCP_TOKEN";
10+
811
public static Command Build()
912
{
1013
var headlessOpt = new Option<bool>("--headless")
@@ -17,30 +20,83 @@ public static Command Build()
1720
Description = "Browser channel to drive (chromium, chrome, edge, firefox)",
1821
DefaultValueFactory = _ => "chromium",
1922
};
23+
var httpOpt = new Option<bool>("--http")
24+
{
25+
Description = "Serve over Streamable HTTP for concurrent remote clients instead of stdio",
26+
DefaultValueFactory = _ => false,
27+
};
28+
var hostOpt = new Option<string>("--host")
29+
{
30+
Description = "Host/interface to bind when --http is set",
31+
DefaultValueFactory = _ => "127.0.0.1",
32+
};
33+
var portOpt = new Option<int>("--port")
34+
{
35+
Description = "TCP port to listen on when --http is set",
36+
DefaultValueFactory = _ => 8931,
37+
};
38+
var tokenOpt = new Option<string?>("--token")
39+
{
40+
Description =
41+
"Bearer token required on every HTTP request (or set " + TokenEnvVar + "). "
42+
+ "Required when binding a non-loopback host.",
43+
};
2044

21-
var cmd = new Command("mcp", "Run the Motus MCP server over stdio for agent clients")
45+
var cmd = new Command("mcp", "Run the Motus MCP server for agent clients (stdio by default, or --http)")
2246
{
2347
headlessOpt,
2448
channelOpt,
49+
httpOpt,
50+
hostOpt,
51+
portOpt,
52+
tokenOpt,
2553
};
2654

2755
cmd.SetAction(async (parseResult, ct) =>
2856
{
2957
var headless = parseResult.GetValue(headlessOpt);
3058
var channelText = parseResult.GetValue(channelOpt)!;
59+
var useHttp = parseResult.GetValue(httpOpt);
3160

3261
// Prefer a browser installed via `motus install`; if none is found,
3362
// leave the path unset so the framework auto-detects a system browser.
3463
var executablePath = BrowserPathHelper.Resolve(channelText);
3564

36-
var options = new McpServerLaunchOptions
65+
var launchOptions = new McpServerLaunchOptions
3766
{
3867
Headless = headless,
3968
ExecutablePath = executablePath,
4069
};
4170

42-
await McpServerHost.RunAsync(options, ct);
43-
return 0;
71+
if (!useHttp)
72+
{
73+
await McpServerHost.RunAsync(launchOptions, ct);
74+
return 0;
75+
}
76+
77+
var token = parseResult.GetValue(tokenOpt)
78+
?? Environment.GetEnvironmentVariable(TokenEnvVar);
79+
80+
var httpOptions = new McpHttpServerOptions
81+
{
82+
Host = parseResult.GetValue(hostOpt)!,
83+
Port = parseResult.GetValue(portOpt),
84+
Token = string.IsNullOrEmpty(token) ? null : token,
85+
LaunchOptions = launchOptions,
86+
};
87+
88+
try
89+
{
90+
await McpHttpServerHost.StartAsync(httpOptions, ct);
91+
return 0;
92+
}
93+
catch (InvalidOperationException ex)
94+
{
95+
// The host refuses to bind a non-loopback address without a token; surface the
96+
// reason instead of a stack trace.
97+
await Console.Error.WriteLineAsync(ex.Message);
98+
return 1;
99+
}
44100
});
45101

46102
return cmd;

src/Motus.Cli/Motus.Cli.csproj

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<ProjectReference Include="..\Motus.Mcp\Motus.Mcp.csproj" />
4141
<ProjectReference Include="..\Motus.Recorder\Motus.Recorder.csproj" />
4242
<ProjectReference Include="..\Motus.Runner\Motus.Runner.csproj" />
43+
<ProjectReference Include="..\Motus.Mcp.Http\Motus.Mcp.Http.csproj" />
4344
</ItemGroup>
4445

4546
<!-- Bundle the static web assets manifest plus the actual wwwroot content
@@ -58,16 +59,10 @@
5859

5960
<Target Name="_IncludeRunnerStaticAssets" AfterTargets="Build">
6061
<ItemGroup>
61-
<TfmSpecificPackageFile Include="$(OutputPath)Motus.Runner.staticwebassets.runtime.json"
62-
Condition="Exists('$(OutputPath)Motus.Runner.staticwebassets.runtime.json')"
63-
PackagePath="tools/$(TargetFramework)/any/" />
64-
<TfmSpecificPackageFile Include="..\Motus.Runner\wwwroot\**\*"
65-
PackagePath="tools/$(TargetFramework)/any/wwwroot/" />
66-
<TfmSpecificPackageFile Include="..\Motus.Runner\obj\$(Configuration)\$(TargetFramework)\scopedcss\bundle\Motus.Runner.styles.css"
67-
Condition="Exists('..\Motus.Runner\obj\$(Configuration)\$(TargetFramework)\scopedcss\bundle\Motus.Runner.styles.css')"
68-
PackagePath="tools/$(TargetFramework)/any/wwwroot/" />
69-
<TfmSpecificPackageFile Include="@(_BlazorFrameworkFile)"
70-
PackagePath="tools/$(TargetFramework)/any/wwwroot/_framework/%(Filename)%(Extension)" />
62+
<TfmSpecificPackageFile Include="$(OutputPath)Motus.Runner.staticwebassets.runtime.json" Condition="Exists('$(OutputPath)Motus.Runner.staticwebassets.runtime.json')" PackagePath="tools/$(TargetFramework)/any/" />
63+
<TfmSpecificPackageFile Include="..\Motus.Runner\wwwroot\**\*" PackagePath="tools/$(TargetFramework)/any/wwwroot/" />
64+
<TfmSpecificPackageFile Include="..\Motus.Runner\obj\$(Configuration)\$(TargetFramework)\scopedcss\bundle\Motus.Runner.styles.css" Condition="Exists('..\Motus.Runner\obj\$(Configuration)\$(TargetFramework)\scopedcss\bundle\Motus.Runner.styles.css')" PackagePath="tools/$(TargetFramework)/any/wwwroot/" />
65+
<TfmSpecificPackageFile Include="@(_BlazorFrameworkFile)" PackagePath="tools/$(TargetFramework)/any/wwwroot/_framework/%(Filename)%(Extension)" />
7166
</ItemGroup>
7267
</Target>
7368

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using System.Net;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Hosting;
8+
using Microsoft.Extensions.Logging;
9+
using Motus.Mcp;
10+
11+
namespace Motus.Mcp.Http;
12+
13+
/// <summary>
14+
/// Hosts the Motus MCP server over Streamable HTTP for concurrent remote clients. Each connected
15+
/// client (MCP session) gets its own isolated browser, contexts, and tabs; the tool set is the same
16+
/// one the stdio host serves.
17+
/// </summary>
18+
public static class McpHttpServerHost
19+
{
20+
/// <summary>
21+
/// Builds and runs the HTTP host until the token is cancelled or the process is stopped.
22+
/// </summary>
23+
public static async Task StartAsync(McpHttpServerOptions options, CancellationToken cancellationToken = default)
24+
{
25+
ArgumentNullException.ThrowIfNull(options);
26+
27+
var (app, registry) = Build(options);
28+
try
29+
{
30+
await app.RunAsync(cancellationToken).ConfigureAwait(false);
31+
}
32+
finally
33+
{
34+
await registry.DisposeAsync().ConfigureAwait(false);
35+
await app.DisposeAsync().ConfigureAwait(false);
36+
}
37+
}
38+
39+
/// <summary>
40+
/// Builds the web application and its session registry without running it. The registry is
41+
/// returned so the caller can dispose it on shutdown (the host registers it as an external
42+
/// instance, which the DI container does not own). Exposed for tests that drive the host on a
43+
/// chosen port and read back the bound address.
44+
/// </summary>
45+
internal static (WebApplication App, McpSessionRegistry Registry) Build(McpHttpServerOptions options)
46+
{
47+
ArgumentNullException.ThrowIfNull(options);
48+
49+
if (!IsLoopback(options.Host) && string.IsNullOrEmpty(options.Token))
50+
{
51+
throw new InvalidOperationException(
52+
$"Refusing to bind non-loopback host '{options.Host}' without an authentication token. "
53+
+ "Pass a token or bind a loopback address (127.0.0.1).");
54+
}
55+
56+
var builder = WebApplication.CreateBuilder();
57+
58+
// Keep the web host quiet on stdout; only warnings and errors surface unless the operator
59+
// raises the level. There is no JSON-RPC-over-stdout concern here (that is the stdio host),
60+
// but a chatty server is noise in a CLI session.
61+
builder.Logging.ClearProviders();
62+
builder.Logging.AddConsole();
63+
builder.Logging.SetMinimumLevel(LogLevel.Warning);
64+
65+
builder.WebHost.UseUrls($"http://{options.Host}:{options.Port}");
66+
67+
// The registry is created here so the per-session handler (a closure) and the per-session
68+
// tool service factories can share it. It is registered as an external instance and disposed
69+
// by the host, not the container.
70+
var registry = new McpSessionRegistry();
71+
builder.Services.AddSingleton(registry);
72+
73+
// The tools resolve these four services by type. Each call returns the bundle for the
74+
// session the call belongs to, so concurrent clients never see each other's browser. The
75+
// bundle owns the instances' lifetimes, so none of these is disposed by the per-call scope.
76+
builder.Services.AddTransient(_ => registry.RequireCurrent().Pages);
77+
builder.Services.AddTransient(_ => registry.RequireCurrent().Dialogs);
78+
builder.Services.AddTransient(_ => registry.RequireCurrent().Console);
79+
builder.Services.AddTransient(_ => registry.RequireCurrent().Network);
80+
81+
var launchOptions = options.LaunchOptions;
82+
83+
var mcpBuilder = builder.Services
84+
.AddMcpServer(McpServerConfiguration.ConfigureServerOptions)
85+
.WithHttpTransport(transport =>
86+
{
87+
transport.IdleTimeout = options.IdleTimeout;
88+
89+
// Run every tool call of a session on the session's execution context, so the
90+
// ambient bundle set below flows into the tool service factories.
91+
transport.PerSessionExecutionContext = true;
92+
93+
// One bundle per session: created when the session starts, made current on this
94+
// execution context, and disposed (browser torn down) when the session ends.
95+
transport.RunSessionHandler = async (httpContext, server, sessionToken) =>
96+
{
97+
var sessionId = server.SessionId
98+
?? throw new InvalidOperationException("A stateful HTTP session has no session id.");
99+
var loggerFactory = httpContext.RequestServices.GetService<ILoggerFactory>();
100+
var bundle = new McpSessionBundle(launchOptions, loggerFactory);
101+
registry.Register(sessionId, bundle);
102+
try
103+
{
104+
await server.RunAsync(sessionToken).ConfigureAwait(false);
105+
}
106+
finally
107+
{
108+
await registry.RemoveAsync(sessionId).ConfigureAwait(false);
109+
}
110+
};
111+
});
112+
mcpBuilder.AddMotusTools();
113+
114+
var app = builder.Build();
115+
116+
// Gate the endpoint on the bearer token when one is configured. Done as terminal middleware
117+
// rather than a full authentication scheme to keep the dependency surface minimal for v1.
118+
if (!string.IsNullOrEmpty(options.Token))
119+
{
120+
var expected = Encoding.UTF8.GetBytes($"Bearer {options.Token}");
121+
app.Use(async (context, next) =>
122+
{
123+
var presented = Encoding.UTF8.GetBytes(context.Request.Headers.Authorization.ToString());
124+
if (!CryptographicOperations.FixedTimeEquals(presented, expected))
125+
{
126+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
127+
context.Response.Headers.WWWAuthenticate = "Bearer";
128+
return;
129+
}
130+
131+
await next().ConfigureAwait(false);
132+
});
133+
}
134+
135+
app.MapMcp();
136+
137+
return (app, registry);
138+
}
139+
140+
/// <summary>
141+
/// Starts the host on its configured (or OS-assigned, when the port is 0) address and returns a
142+
/// handle exposing the bound base address and the session registry. For tests that drive the
143+
/// running server with a real MCP client.
144+
/// </summary>
145+
internal static async Task<RunningServer> StartForTestingAsync(
146+
McpHttpServerOptions options,
147+
CancellationToken cancellationToken = default)
148+
{
149+
var (app, registry) = Build(options);
150+
await app.StartAsync(cancellationToken).ConfigureAwait(false);
151+
var baseAddress = new Uri(app.Urls.First(), UriKind.Absolute);
152+
return new RunningServer(app, registry, baseAddress);
153+
}
154+
155+
/// <summary>A running HTTP host handle for tests: the bound address, the registry, and teardown.</summary>
156+
internal sealed class RunningServer : IAsyncDisposable
157+
{
158+
private readonly WebApplication _app;
159+
160+
internal RunningServer(WebApplication app, McpSessionRegistry registry, Uri baseAddress)
161+
{
162+
_app = app;
163+
Registry = registry;
164+
BaseAddress = baseAddress;
165+
}
166+
167+
/// <summary>The base address the server is listening on.</summary>
168+
public Uri BaseAddress { get; }
169+
170+
/// <summary>The live session registry, so tests can assert per-session lifecycle.</summary>
171+
public McpSessionRegistry Registry { get; }
172+
173+
public async ValueTask DisposeAsync()
174+
{
175+
await _app.StopAsync().ConfigureAwait(false);
176+
await Registry.DisposeAsync().ConfigureAwait(false);
177+
await _app.DisposeAsync().ConfigureAwait(false);
178+
}
179+
}
180+
181+
private static bool IsLoopback(string host)
182+
{
183+
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
184+
{
185+
return true;
186+
}
187+
188+
return IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address);
189+
}
190+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Motus.Mcp;
2+
3+
namespace Motus.Mcp.Http;
4+
5+
/// <summary>
6+
/// Configuration for the Streamable HTTP MCP host: where it binds, how it is protected, and the
7+
/// browser options each session launches with.
8+
/// </summary>
9+
public sealed record McpHttpServerOptions
10+
{
11+
/// <summary>The host/interface to bind. Defaults to the loopback address.</summary>
12+
public string Host { get; init; } = "127.0.0.1";
13+
14+
/// <summary>The TCP port to listen on.</summary>
15+
public int Port { get; init; } = 8931;
16+
17+
/// <summary>
18+
/// An optional bearer token. When set, every request to the MCP endpoint must carry
19+
/// <c>Authorization: Bearer &lt;token&gt;</c>. Binding a non-loopback host without a token is
20+
/// refused.
21+
/// </summary>
22+
public string? Token { get; init; }
23+
24+
/// <summary>
25+
/// How long a session may sit idle before the server tears it down (and its browser with it).
26+
/// Defaults to 30 minutes so abandoned remote sessions do not keep a browser alive indefinitely.
27+
/// </summary>
28+
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromMinutes(30);
29+
30+
/// <summary>The browser launch options each session uses when it first needs a page.</summary>
31+
public McpServerLaunchOptions LaunchOptions { get; init; } = new();
32+
}

0 commit comments

Comments
 (0)