Skip to content

Commit c2e3b63

Browse files
committed
Add Motus.Mcp server, session manager, and tests
Introduce a new Motus.Mcp project implementing an MCP (Model Context Protocol) server and browser session lifecycle. Adds McpServerHost (stdio/stream transports, logging to stderr), McpServerLaunchOptions, and BrowserSessionManager (lazy browser launch, multi-context management, graceful teardown). Wire a new "mcp" CLI command and add project references and solution entries. Include integration tests for the MCP handshake and BrowserSessionManager, and update package/reference versions as needed.
1 parent a410ec3 commit c2e3b63

12 files changed

Lines changed: 694 additions & 1 deletion

Motus.sln

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20
4545
EndProject
4646
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motus.Samples", "samples\Motus.Samples\Motus.Samples.csproj", "{6B1A49AD-10DC-477A-81A3-255F95F4C253}"
4747
EndProject
48+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motus.Mcp", "src\Motus.Mcp\Motus.Mcp.csproj", "{2B966099-CDAA-41B3-9419-2CAE298A1C68}"
49+
EndProject
50+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motus.Mcp.Tests", "tests\Motus.Mcp.Tests\Motus.Mcp.Tests.csproj", "{110037A6-2F59-4273-B115-5047BDC39CE6}"
51+
EndProject
4852
Global
4953
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5054
Debug|Any CPU = Debug|Any CPU
@@ -271,6 +275,30 @@ Global
271275
{6B1A49AD-10DC-477A-81A3-255F95F4C253}.Release|x64.Build.0 = Release|Any CPU
272276
{6B1A49AD-10DC-477A-81A3-255F95F4C253}.Release|x86.ActiveCfg = Release|Any CPU
273277
{6B1A49AD-10DC-477A-81A3-255F95F4C253}.Release|x86.Build.0 = Release|Any CPU
278+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
279+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Debug|Any CPU.Build.0 = Debug|Any CPU
280+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Debug|x64.ActiveCfg = Debug|Any CPU
281+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Debug|x64.Build.0 = Debug|Any CPU
282+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Debug|x86.ActiveCfg = Debug|Any CPU
283+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Debug|x86.Build.0 = Debug|Any CPU
284+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Release|Any CPU.ActiveCfg = Release|Any CPU
285+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Release|Any CPU.Build.0 = Release|Any CPU
286+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Release|x64.ActiveCfg = Release|Any CPU
287+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Release|x64.Build.0 = Release|Any CPU
288+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Release|x86.ActiveCfg = Release|Any CPU
289+
{2B966099-CDAA-41B3-9419-2CAE298A1C68}.Release|x86.Build.0 = Release|Any CPU
290+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
291+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
292+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Debug|x64.ActiveCfg = Debug|Any CPU
293+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Debug|x64.Build.0 = Debug|Any CPU
294+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Debug|x86.ActiveCfg = Debug|Any CPU
295+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Debug|x86.Build.0 = Debug|Any CPU
296+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
297+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|Any CPU.Build.0 = Release|Any CPU
298+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x64.ActiveCfg = Release|Any CPU
299+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x64.Build.0 = Release|Any CPU
300+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x86.ActiveCfg = Release|Any CPU
301+
{110037A6-2F59-4273-B115-5047BDC39CE6}.Release|x86.Build.0 = Release|Any CPU
274302
EndGlobalSection
275303
GlobalSection(SolutionProperties) = preSolution
276304
HideSolutionNode = FALSE
@@ -294,6 +322,8 @@ Global
294322
{D4A3C6E1-7B2F-4E8A-9C5D-1F6A8B3E2D47} = {C2BE1865-1543-4EBB-9E95-92EE083BF967}
295323
{A8E5F2B1-3C6D-4A9E-B7F1-2D4C8E6A5B39} = {47F5729D-17CB-4046-88FA-BB0F9F660B6E}
296324
{6B1A49AD-10DC-477A-81A3-255F95F4C253} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
325+
{2B966099-CDAA-41B3-9419-2CAE298A1C68} = {C2BE1865-1543-4EBB-9E95-92EE083BF967}
326+
{110037A6-2F59-4273-B115-5047BDC39CE6} = {47F5729D-17CB-4046-88FA-BB0F9F660B6E}
297327
EndGlobalSection
298328
GlobalSection(ExtensibilityGlobals) = postSolution
299329
SolutionGuid = {3A99AF87-BFA9-49CF-A20A-71753BE54A53}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System.CommandLine;
2+
using Motus.Mcp;
3+
4+
namespace Motus.Cli.Commands;
5+
6+
public static class McpCommand
7+
{
8+
public static Command Build()
9+
{
10+
var headlessOpt = new Option<bool>("--headless")
11+
{
12+
Description = "Run the browser without a visible window",
13+
DefaultValueFactory = _ => true,
14+
};
15+
var channelOpt = new Option<string>("--channel")
16+
{
17+
Description = "Browser channel to drive (chromium, chrome, edge, firefox)",
18+
DefaultValueFactory = _ => "chromium",
19+
};
20+
21+
var cmd = new Command("mcp", "Run the Motus MCP server over stdio for agent clients")
22+
{
23+
headlessOpt,
24+
channelOpt,
25+
};
26+
27+
cmd.SetAction(async (parseResult, ct) =>
28+
{
29+
var headless = parseResult.GetValue(headlessOpt);
30+
var channelText = parseResult.GetValue(channelOpt)!;
31+
32+
// Prefer a browser installed via `motus install`; if none is found,
33+
// leave the path unset so the framework auto-detects a system browser.
34+
var executablePath = BrowserPathHelper.Resolve(channelText);
35+
36+
var options = new McpServerLaunchOptions
37+
{
38+
Headless = headless,
39+
ExecutablePath = executablePath,
40+
};
41+
42+
await McpServerHost.RunAsync(options, ct);
43+
return 0;
44+
});
45+
46+
return cmd;
47+
}
48+
}

src/Motus.Cli/Motus.Cli.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<ItemGroup>
2828
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25277.114" />
2929
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
30-
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="8.0.0" />
30+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.7" />
3131
</ItemGroup>
3232

3333
<ItemGroup>
@@ -37,6 +37,7 @@
3737
<ItemGroup>
3838
<ProjectReference Include="..\Motus\Motus.csproj" />
3939
<ProjectReference Include="..\Motus.Abstractions\Motus.Abstractions.csproj" />
40+
<ProjectReference Include="..\Motus.Mcp\Motus.Mcp.csproj" />
4041
<ProjectReference Include="..\Motus.Recorder\Motus.Recorder.csproj" />
4142
<ProjectReference Include="..\Motus.Runner\Motus.Runner.csproj" />
4243
</ItemGroup>

src/Motus.Cli/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
TraceCommand.Build(),
1414
TrxCommand.Build(),
1515
CheckSelectorsCommand.Build(),
16+
McpCommand.Build(),
1617
};
1718

1819
var config = new CommandLineConfiguration(root);
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Motus;
4+
using Motus.Abstractions;
5+
6+
namespace Motus.Mcp;
7+
8+
/// <summary>
9+
/// Owns the live browser for the lifetime of an MCP server session. Tool calls
10+
/// arrive as individually stateless messages, so this holder keeps the browser,
11+
/// its isolated contexts, and the active selection between calls.
12+
/// </summary>
13+
/// <remarks>
14+
/// The model is intentionally multi-context from the start: one browser holds
15+
/// several named, isolated contexts (each with its own cookies and storage), and
16+
/// one of them is active at any time. An implicit <see cref="DefaultContextName"/>
17+
/// context is created on first use, so a caller that never touches named contexts
18+
/// sees a plain single-session model. Element-addressing and per-tab state are
19+
/// layered on later by the components that consume them.
20+
/// </remarks>
21+
public sealed class BrowserSessionManager : IAsyncDisposable
22+
{
23+
/// <summary>The name of the context created implicitly on first use.</summary>
24+
public const string DefaultContextName = "default";
25+
26+
private readonly McpServerLaunchOptions _options;
27+
private readonly ILogger<BrowserSessionManager> _logger;
28+
private readonly SemaphoreSlim _gate = new(1, 1);
29+
private readonly Dictionary<string, IBrowserContext> _contexts = new(StringComparer.Ordinal);
30+
31+
private IBrowser? _browser;
32+
private int _disposed;
33+
34+
public BrowserSessionManager(McpServerLaunchOptions options, ILogger<BrowserSessionManager>? logger = null)
35+
{
36+
_options = options;
37+
_logger = logger ?? NullLogger<BrowserSessionManager>.Instance;
38+
}
39+
40+
/// <summary>The name of the context that unscoped tool calls act on.</summary>
41+
public string ActiveContextName { get; private set; } = DefaultContextName;
42+
43+
/// <summary>Whether the browser process has been launched.</summary>
44+
public bool IsBrowserLaunched => _browser is not null;
45+
46+
/// <summary>A snapshot of the currently open context names.</summary>
47+
public IReadOnlyCollection<string> ContextNames => _contexts.Keys.ToArray();
48+
49+
/// <summary>
50+
/// Returns the live browser, launching it lazily on first use. Concurrent
51+
/// first callers share a single launch.
52+
/// </summary>
53+
public async Task<IBrowser> EnsureBrowserAsync(CancellationToken cancellationToken = default)
54+
{
55+
if (_browser is not null)
56+
{
57+
return _browser;
58+
}
59+
60+
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
61+
try
62+
{
63+
ThrowIfDisposed();
64+
if (_browser is null)
65+
{
66+
_logger.LogInformation(
67+
"Launching browser (headless={Headless}, channel={Channel}).",
68+
_options.Headless,
69+
_options.Channel);
70+
_browser = await MotusLauncher.LaunchAsync(_options.ToLaunchOptions(), cancellationToken)
71+
.ConfigureAwait(false);
72+
}
73+
74+
return _browser;
75+
}
76+
finally
77+
{
78+
_gate.Release();
79+
}
80+
}
81+
82+
/// <summary>
83+
/// Returns the active context, creating it (and launching the browser) on
84+
/// first use.
85+
/// </summary>
86+
public Task<IBrowserContext> GetOrCreateActiveContextAsync(CancellationToken cancellationToken = default)
87+
=> GetOrCreateContextAsync(ActiveContextName, cancellationToken);
88+
89+
/// <summary>
90+
/// Creates a new isolated context with the given name and makes it active.
91+
/// </summary>
92+
/// <exception cref="InvalidOperationException">A context with that name already exists.</exception>
93+
public async Task<IBrowserContext> CreateContextAsync(string name, CancellationToken cancellationToken = default)
94+
{
95+
ArgumentException.ThrowIfNullOrEmpty(name);
96+
var browser = await EnsureBrowserAsync(cancellationToken).ConfigureAwait(false);
97+
98+
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
99+
try
100+
{
101+
ThrowIfDisposed();
102+
if (_contexts.ContainsKey(name))
103+
{
104+
throw new InvalidOperationException($"A context named '{name}' already exists.");
105+
}
106+
107+
var context = await browser.NewContextAsync().ConfigureAwait(false);
108+
_contexts[name] = context;
109+
ActiveContextName = name;
110+
return context;
111+
}
112+
finally
113+
{
114+
_gate.Release();
115+
}
116+
}
117+
118+
/// <summary>Makes an existing context active.</summary>
119+
/// <exception cref="InvalidOperationException">No context with that name is open.</exception>
120+
public void SelectContext(string name)
121+
{
122+
ArgumentException.ThrowIfNullOrEmpty(name);
123+
if (!_contexts.ContainsKey(name))
124+
{
125+
throw new InvalidOperationException($"No open context named '{name}'.");
126+
}
127+
128+
ActiveContextName = name;
129+
}
130+
131+
/// <summary>
132+
/// Closes the named context and its pages. If the active context is closed,
133+
/// the active selection falls back to <see cref="DefaultContextName"/>.
134+
/// </summary>
135+
public async Task CloseContextAsync(string name, CancellationToken cancellationToken = default)
136+
{
137+
ArgumentException.ThrowIfNullOrEmpty(name);
138+
139+
IBrowserContext? context;
140+
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
141+
try
142+
{
143+
if (!_contexts.Remove(name, out context))
144+
{
145+
return;
146+
}
147+
148+
if (ActiveContextName == name)
149+
{
150+
ActiveContextName = DefaultContextName;
151+
}
152+
}
153+
finally
154+
{
155+
_gate.Release();
156+
}
157+
158+
await context.CloseAsync().ConfigureAwait(false);
159+
}
160+
161+
private async Task<IBrowserContext> GetOrCreateContextAsync(string name, CancellationToken cancellationToken)
162+
{
163+
var browser = await EnsureBrowserAsync(cancellationToken).ConfigureAwait(false);
164+
165+
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
166+
try
167+
{
168+
ThrowIfDisposed();
169+
if (_contexts.TryGetValue(name, out var existing))
170+
{
171+
return existing;
172+
}
173+
174+
var context = await browser.NewContextAsync().ConfigureAwait(false);
175+
_contexts[name] = context;
176+
return context;
177+
}
178+
finally
179+
{
180+
_gate.Release();
181+
}
182+
}
183+
184+
private void ThrowIfDisposed()
185+
{
186+
ObjectDisposedException.ThrowIf(_disposed != 0, this);
187+
}
188+
189+
/// <summary>
190+
/// Tears the session down: closes every context, then the browser. Safe to
191+
/// call more than once.
192+
/// </summary>
193+
public async ValueTask DisposeAsync()
194+
{
195+
if (Interlocked.Exchange(ref _disposed, 1) != 0)
196+
{
197+
return;
198+
}
199+
200+
await _gate.WaitAsync().ConfigureAwait(false);
201+
try
202+
{
203+
foreach (var context in _contexts.Values)
204+
{
205+
try
206+
{
207+
await context.CloseAsync().ConfigureAwait(false);
208+
}
209+
catch (Exception ex)
210+
{
211+
_logger.LogWarning(ex, "Failed to close a browser context during shutdown.");
212+
}
213+
}
214+
215+
_contexts.Clear();
216+
217+
if (_browser is not null)
218+
{
219+
try
220+
{
221+
await _browser.CloseAsync().ConfigureAwait(false);
222+
}
223+
catch (Exception ex)
224+
{
225+
_logger.LogWarning(ex, "Failed to close the browser cleanly; forcing disposal.");
226+
await _browser.DisposeAsync().ConfigureAwait(false);
227+
}
228+
229+
_browser = null;
230+
}
231+
}
232+
finally
233+
{
234+
_gate.Release();
235+
_gate.Dispose();
236+
}
237+
}
238+
}

0 commit comments

Comments
 (0)