feat(mcp): Playwright preset and server resolution
Spec: the preset defaults --browser to chromium and drops --user-data-dir with --isolated; source order and the send-less prompt line are spelled out.
This commit is contained in:
@@ -122,6 +122,9 @@ two meet only in tool naming (see "Names").
|
||||
them all through).
|
||||
- Validation (`McpOptions.Validate()`) rejects empty commands, non-positive timeouts, and prefixes that don't match
|
||||
`[A-Za-z0-9_-]*` or that start with `mcp__`.
|
||||
- Order: `Mcp:Servers` in configuration order, then the Playwright preset (skipped with a warning when a server is
|
||||
explicitly named `playwright`), then the user settings. The first source wins on a server name. `/mcp` lists a
|
||||
disabled preset as `playwright: disabled (set PLAYWRIGHT_ENABLED=true)`.
|
||||
- The extension has two constructors, `McpExtension(McpOptions)` for DI and a parameterless one that reads only
|
||||
the environment and user settings, so a dropped-in DLL works without host code. The agent dir is
|
||||
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`.
|
||||
@@ -133,7 +136,7 @@ When enabled, it expands to server `playwright`:
|
||||
```
|
||||
command: npx (or Playwright:Command / PLAYWRIGHT_COMMAND, e.g. the globally installed `playwright-mcp`)
|
||||
args: -y @playwright/mcp@<pinned> (only with npx; Playwright:Package overrides the spec)
|
||||
[--headless] [--browser <b>]
|
||||
[--headless] --browser <b, default chromium>
|
||||
--output-dir ${workspace}/browser
|
||||
--user-data-dir ${workspace}/.volcanic/browser-profile
|
||||
<ExtraArgs...>
|
||||
@@ -150,6 +153,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
|
||||
(hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally
|
||||
and compose sets `PLAYWRIGHT_COMMAND=playwright-mcp`, so a container never downloads anything and the first chat
|
||||
doesn't stall.
|
||||
- Without `--browser`, `@playwright/mcp` uses the Chrome channel, which the deployment doesn't install (`playwright
|
||||
install chromium` provides Chromium). The preset therefore passes `--browser chromium` unless `Browser` is set.
|
||||
With `--isolated` in `ExtraArgs` the preset leaves out `--user-data-dir`.
|
||||
- **Chromium sandbox.** The preset doesn't add `--no-sandbox` itself. Chromium's sandbox needs user namespaces, which
|
||||
Docker's default seccomp profile blocks for a non-root user, so the Docker compose file sets
|
||||
`PLAYWRIGHT_EXTRA_ARGS=--no-sandbox` and the container is the isolation boundary. Bare hosts keep the sandbox.
|
||||
@@ -181,7 +187,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
|
||||
```
|
||||
|
||||
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
|
||||
`GetAllTools()`), so the preset still reads correctly on hosts without attachments.
|
||||
`GetAllTools()`), so the preset still reads correctly on hosts without attachments. Without `send`, that bullet
|
||||
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
|
||||
under browser/ in the workspace."
|
||||
|
||||
### 3. Catalogue — `McpCatalogCache`
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ public sealed class McpOptions
|
||||
foreach (var (name, server) in Servers) errors.AddRange(server.Validate(name));
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>The startup log line: <c>MCP: playwright enabled (headless, chromium)</c> or how to enable it.</summary>
|
||||
public string DescribePlaywright() => PlaywrightPreset.Describe(Playwright);
|
||||
}
|
||||
|
||||
/// <summary>One stdio MCP server. <c>${workspace}</c> expands in command, args, cwd and env; <c>${env:NAME}</c> in env values.</summary>
|
||||
@@ -103,7 +106,7 @@ public sealed class PlaywrightOptions
|
||||
public bool? Headless { get; set; }
|
||||
public string? Browser { get; set; }
|
||||
public List<string>? ExtraArgs { get; set; }
|
||||
/// <summary>npm package spec used with npx. Defaults to <c>PlaywrightPreset.DefaultPackage</c>.</summary>
|
||||
/// <summary>npm package spec used with npx. Defaults to <see cref="PlaywrightPreset.DefaultPackage"/>.</summary>
|
||||
public string? Package { get; set; }
|
||||
/// <summary>Run this binary instead of <c>npx -y <package></c> (e.g. the globally installed <c>playwright-mcp</c>).</summary>
|
||||
public string? Command { get; set; }
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>One configured server as a session sees it: config, prompt section and whether it can run.</summary>
|
||||
internal sealed record McpServerEntry
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required McpServerConfig Config { get; init; }
|
||||
/// <summary>Key in <c>SystemPromptOptions.Sections</c>.</summary>
|
||||
public required string SectionKey { get; init; }
|
||||
/// <summary>The prompt section given whether a <c>send</c> tool exists; null for none.</summary>
|
||||
public required Func<bool, string?> PromptSection { get; init; }
|
||||
public bool Disabled { get; init; }
|
||||
public string? DisabledHint { get; init; }
|
||||
/// <summary>Validation errors; a server with a problem is listed but never started.</summary>
|
||||
public string? Problem { get; init; }
|
||||
|
||||
public static McpServerEntry For(string name, McpServerConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
var errors = config.Validate(name);
|
||||
return new()
|
||||
{
|
||||
Name = name,
|
||||
Config = config,
|
||||
SectionKey = name,
|
||||
PromptSection = _ => config.PromptSection,
|
||||
Disabled = !config.Enabled,
|
||||
Problem = errors.Count == 0 ? null : string.Join(" ", errors),
|
||||
};
|
||||
}
|
||||
|
||||
public static McpServerEntry Preset(PlaywrightOptions options) => new()
|
||||
{
|
||||
Name = PlaywrightPreset.ServerName,
|
||||
Config = PlaywrightPreset.ToServer(options),
|
||||
SectionKey = PlaywrightPreset.SectionKey,
|
||||
PromptSection = hasSend => PlaywrightPreset.PromptSection(hasSend),
|
||||
};
|
||||
|
||||
public static McpServerEntry DisabledPreset() => new()
|
||||
{
|
||||
Name = PlaywrightPreset.ServerName,
|
||||
Config = new McpServerConfig(),
|
||||
SectionKey = PlaywrightPreset.SectionKey,
|
||||
PromptSection = _ => null,
|
||||
Disabled = true,
|
||||
DisabledHint = "set PLAYWRIGHT_ENABLED=true",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Merges the configuration sources. The first source wins a server name.</summary>
|
||||
internal static class McpServerSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Host servers in configuration order, then the Playwright preset when enabled, then the user settings. A taken
|
||||
/// name is skipped with a log line. A disabled preset is listed last, only while <c>playwright</c> is still free.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<McpServerEntry> Resolve(McpOptions options, IEnumerable<KeyValuePair<string, McpServerConfig>> userServers, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(userServers);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
var entries = new List<McpServerEntry>();
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var (name, config) in options.Servers)
|
||||
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
|
||||
|
||||
if (options.Playwright.IsEnabled)
|
||||
{
|
||||
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.Preset(options.Playwright));
|
||||
else logger.LogWarning("MCP server 'playwright' is configured explicitly; the Playwright preset is ignored.");
|
||||
}
|
||||
|
||||
foreach (var (name, config) in userServers)
|
||||
{
|
||||
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
|
||||
else logger.LogInformation("MCP server '{Name}' in the user settings is ignored: the host configuration defines it.", name);
|
||||
}
|
||||
|
||||
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.DisabledPreset());
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
/// <summary>The <c>@playwright/mcp</c> preset: a server named <c>playwright</c> whose output and profile live in the chat workspace.</summary>
|
||||
public static class PlaywrightPreset
|
||||
{
|
||||
public const string ServerName = "playwright";
|
||||
/// <summary>The single source of truth for the version. The Dockerfile's <c>PLAYWRIGHT_MCP_VERSION</c> must match it.</summary>
|
||||
public const string DefaultPackage = "@playwright/mcp@0.0.82";
|
||||
/// <summary>Without <c>--browser</c>, @playwright/mcp uses the Chrome channel, which the deployment doesn't install.</summary>
|
||||
public const string DefaultBrowser = "chromium";
|
||||
/// <summary>Key of the preset's section in the system prompt.</summary>
|
||||
public const string SectionKey = "browser";
|
||||
|
||||
/// <summary>The exact version in <see cref="DefaultPackage"/>.</summary>
|
||||
public static string PackageVersion => DefaultPackage[(DefaultPackage.LastIndexOf('@') + 1)..];
|
||||
|
||||
/// <summary>The server entry the preset expands into.</summary>
|
||||
public static McpServerConfig ToServer(PlaywrightOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var args = new List<string>();
|
||||
var command = options.Command;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
command = "npx";
|
||||
args.Add("-y");
|
||||
args.Add(string.IsNullOrWhiteSpace(options.Package) ? DefaultPackage : options.Package);
|
||||
}
|
||||
if (options.IsHeadless) args.Add("--headless");
|
||||
args.Add("--browser");
|
||||
args.Add(Browser(options));
|
||||
args.Add("--output-dir");
|
||||
args.Add("${workspace}/browser");
|
||||
var extra = options.ExtraArgs ?? [];
|
||||
if (!extra.Contains("--isolated"))
|
||||
{
|
||||
args.Add("--user-data-dir");
|
||||
args.Add("${workspace}/.volcanic/browser-profile");
|
||||
}
|
||||
args.AddRange(extra);
|
||||
return new McpServerConfig
|
||||
{
|
||||
Command = command,
|
||||
Args = args,
|
||||
Cwd = "${workspace}",
|
||||
StartupTimeoutSeconds = 120,
|
||||
CallTimeoutSeconds = 120,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The startup log line: whether the preset is on, and with which mode and browser.</summary>
|
||||
public static string Describe(PlaywrightOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return options.IsEnabled
|
||||
? $"MCP: playwright enabled ({(options.IsHeadless ? "headless" : "headed")}, {Browser(options)})"
|
||||
: "MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)";
|
||||
}
|
||||
|
||||
/// <summary>The <c>browser</c> prompt section. The <c>send</c> line appears only when a <c>send</c> tool is registered.</summary>
|
||||
public static string PromptSection(bool hasSend)
|
||||
{
|
||||
string[] files = hasSend
|
||||
?
|
||||
[
|
||||
"- To give the user a screenshot, save it with `filename: \"browser/<name>.png\"` and then call `send` with that",
|
||||
" path. Files the browser saves (PDFs, downloads) are under browser/ too.",
|
||||
]
|
||||
:
|
||||
[
|
||||
"- Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are",
|
||||
" under browser/ in the workspace.",
|
||||
];
|
||||
string[] lines =
|
||||
[
|
||||
"## Browser",
|
||||
"- browser_* tools drive a real browser. Use browser_snapshot (an accessibility tree with element refs) to read a",
|
||||
" page and find refs for click/type; it is cheaper and more reliable than screenshots.",
|
||||
"- Take a screenshot without `filename` only when you need to see the visual layout yourself; the image comes back",
|
||||
" to you.",
|
||||
.. files,
|
||||
"- Close the browser (browser_close) when a task is finished. Browser state may be reset after a long pause.",
|
||||
"- Don't enter passwords or payment details unless the user explicitly asks for that specific site.",
|
||||
];
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string Browser(PlaywrightOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.Browser) ? DefaultBrowser : options.Browser;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
|
||||
|
||||
public class McpServerSetTests
|
||||
{
|
||||
private static McpServerConfig Server(string command) => new() { Command = command };
|
||||
|
||||
[Fact]
|
||||
public void Host_servers_then_the_preset_then_user_servers()
|
||||
{
|
||||
var options = new McpOptions { Servers = { ["b"] = Server("b"), ["a"] = Server("a") }, Playwright = new PlaywrightOptions { Enabled = true } };
|
||||
var entries = McpServerSet.Resolve(options, [new("user", Server("u"))], new RecordingLogger());
|
||||
|
||||
Assert.Equal(["b", "a", "playwright", "user"], entries.Select(e => e.Name));
|
||||
var preset = entries[2];
|
||||
Assert.Equal("browser", preset.SectionKey);
|
||||
Assert.Equal("npx", preset.Config.Command);
|
||||
Assert.False(preset.Disabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_source_wins_on_a_server_name()
|
||||
{
|
||||
var options = new McpOptions { Servers = { ["github"] = Server("host") } };
|
||||
var logger = new RecordingLogger();
|
||||
var entries = McpServerSet.Resolve(options, [new("github", Server("user")), new("extra", Server("x"))], logger);
|
||||
|
||||
Assert.Equal("host", entries.Single(e => e.Name == "github").Config.Command);
|
||||
Assert.Contains(entries, e => e.Name == "extra");
|
||||
Assert.True(logger.Has(LogLevel.Information, "github"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_explicit_playwright_server_replaces_the_preset()
|
||||
{
|
||||
var options = new McpOptions { Servers = { ["playwright"] = Server("mine") }, Playwright = new PlaywrightOptions { Enabled = true } };
|
||||
var logger = new RecordingLogger();
|
||||
var entry = Assert.Single(McpServerSet.Resolve(options, [], logger));
|
||||
|
||||
Assert.Equal("mine", entry.Config.Command);
|
||||
Assert.Equal("playwright", entry.SectionKey);
|
||||
Assert.True(logger.Has(LogLevel.Warning, "preset"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_preset_is_listed_last_when_its_name_is_free()
|
||||
{
|
||||
var entries = McpServerSet.Resolve(new McpOptions(), [new("user", Server("u"))], new RecordingLogger());
|
||||
Assert.Equal(["user", "playwright"], entries.Select(e => e.Name));
|
||||
Assert.True(entries[1].Disabled);
|
||||
Assert.Equal("set PLAYWRIGHT_ENABLED=true", entries[1].DisabledHint);
|
||||
|
||||
var taken = McpServerSet.Resolve(new McpOptions(), [new("playwright", Server("u"))], new RecordingLogger());
|
||||
Assert.False(Assert.Single(taken).Disabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_and_invalid_servers_are_listed_with_their_state()
|
||||
{
|
||||
var disabled = Server("d");
|
||||
disabled.Enabled = false;
|
||||
var options = new McpOptions { Servers = { ["off"] = disabled, ["bad"] = Server("") } };
|
||||
var entries = McpServerSet.Resolve(options, [], new RecordingLogger());
|
||||
|
||||
Assert.True(entries.Single(e => e.Name == "off").Disabled);
|
||||
Assert.Contains("command is required", entries.Single(e => e.Name == "bad").Problem, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generic_prompt_section_comes_from_the_config()
|
||||
{
|
||||
var config = Server("x");
|
||||
config.PromptSection = "Use x wisely.";
|
||||
var entry = McpServerEntry.For("x", config);
|
||||
Assert.Equal("x", entry.SectionKey);
|
||||
Assert.Equal("Use x wisely.", entry.PromptSection(true));
|
||||
Assert.Equal(PlaywrightPreset.PromptSection(false), McpServerEntry.Preset(new PlaywrightOptions { Enabled = true }).PromptSection(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
|
||||
|
||||
public class PlaywrightPresetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_argv_runs_the_pinned_package_headless_with_chromium_and_workspace_paths()
|
||||
{
|
||||
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true });
|
||||
|
||||
Assert.Equal("npx", server.Command);
|
||||
Assert.Equal(
|
||||
["-y", PlaywrightPreset.DefaultPackage, "--headless", "--browser", "chromium",
|
||||
"--output-dir", "${workspace}/browser", "--user-data-dir", "${workspace}/.volcanic/browser-profile"],
|
||||
server.Args);
|
||||
Assert.Equal("${workspace}", server.Cwd);
|
||||
Assert.Null(server.ToolPrefix);
|
||||
Assert.Equal(120, server.StartupTimeoutSeconds);
|
||||
Assert.Equal(120, server.CallTimeoutSeconds);
|
||||
Assert.Empty(server.Env);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headed_browser_and_extra_args_are_passed_through()
|
||||
{
|
||||
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Headless = false, Browser = "firefox", ExtraArgs = ["--no-sandbox", "--device", "iPhone 15"] });
|
||||
|
||||
Assert.DoesNotContain("--headless", server.Args);
|
||||
Assert.Equal("firefox", server.Args[server.Args.IndexOf("--browser") + 1]);
|
||||
Assert.Equal(["--no-sandbox", "--device", "iPhone 15"], server.Args.TakeLast(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Isolated_extra_arg_drops_the_profile_directory()
|
||||
{
|
||||
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, ExtraArgs = ["--isolated"] });
|
||||
Assert.DoesNotContain("--user-data-dir", server.Args);
|
||||
Assert.Contains("--isolated", server.Args);
|
||||
Assert.Contains("--output-dir", server.Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Package_override_replaces_the_pinned_spec()
|
||||
{
|
||||
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Package = "@playwright/mcp@0.0.1" });
|
||||
Assert.Equal(["-y", "@playwright/mcp@0.0.1"], server.Args.Take(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_runs_the_binary_directly_without_the_package()
|
||||
{
|
||||
var server = PlaywrightPreset.ToServer(new PlaywrightOptions { Enabled = true, Command = "playwright-mcp", Package = "ignored@1.0.0" });
|
||||
|
||||
Assert.Equal("playwright-mcp", server.Command);
|
||||
Assert.DoesNotContain("-y", server.Args);
|
||||
Assert.DoesNotContain(server.Args, a => a.Contains('@'));
|
||||
Assert.Equal("--headless", server.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_package_is_pinned_to_an_exact_version()
|
||||
{
|
||||
Assert.Matches(new Regex(@"^@playwright/mcp@\d+\.\d+\.\d+$"), PlaywrightPreset.DefaultPackage);
|
||||
Assert.Equal("0.0.82", PlaywrightPreset.PackageVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_reports_enabled_and_disabled()
|
||||
{
|
||||
Assert.Equal("MCP: playwright enabled (headless, chromium)", PlaywrightPreset.Describe(new PlaywrightOptions { Enabled = true }));
|
||||
Assert.Equal("MCP: playwright enabled (headed, firefox)", PlaywrightPreset.Describe(new PlaywrightOptions { Enabled = true, Headless = false, Browser = "firefox" }));
|
||||
Assert.Equal("MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)", PlaywrightPreset.Describe(new PlaywrightOptions()));
|
||||
Assert.Equal(PlaywrightPreset.Describe(new PlaywrightOptions()), new McpOptions().DescribePlaywright());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prompt_section_mentions_send_only_when_asked()
|
||||
{
|
||||
var withSend = PlaywrightPreset.PromptSection(hasSend: true);
|
||||
var without = PlaywrightPreset.PromptSection(hasSend: false);
|
||||
|
||||
Assert.StartsWith("## Browser", withSend, StringComparison.Ordinal);
|
||||
Assert.Contains("call `send` with that", withSend, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("`send`", without, StringComparison.Ordinal);
|
||||
Assert.Contains("browser/", without, StringComparison.Ordinal);
|
||||
Assert.Contains("browser_close", without, StringComparison.Ordinal);
|
||||
Assert.Contains("passwords", without, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user