Skip to content

Step 4: Connect an external tool safely

Time: 20 minutes

You’ll start Playwright through MCP, limit navigation to the workshop target you provide, inspect its accessibility tree, and report the page title.

The Model Context Protocol (MCP) is a standard way to connect an agent to reusable capabilities implemented outside your application. In this workshop, the SDK starts the Playwright MCP server as a separate npx process. Playwright handles browser automation, while your application configures the connection.

The process boundary is also a trust boundary. A permission handler decides whether each requested external action may run.

Reuse browser automation without giving it free rein

Section titled “Reuse browser automation without giving it free rein”

MCP lets you use Playwright’s browser automation without recreating it as a C# callback. The boundary also makes ownership explicit:

QuestionLocal WCAG toolPlaywright MCP
Who implements it?This applicationExternal Playwright package
Where does it run?Same .NET processSeparate Node.js process
What is it best for?App-owned data and deterministic logicReusable browser capability
How is trust handled here?Read-only tool skips permissionTool list and custom handler restrict access

The WCAG lookup and narrow snapshot reader stay in process. CopilotSession -> Playwright MCP -> browser crosses a process boundary.

At the top of Program.cs, after the using statements and before the banner, insert:

if (args.Length is not 1 ||
!Uri.TryCreate(args[0], UriKind.Absolute, out var targetUri) ||
targetUri.Scheme is not ("http" or "https"))
{
Console.Error.WriteLine("Usage: dotnet run --project workshop-app -- <http-or-https-url>");
return;
}

2. Add Playwright MCP and scoped permissions

Section titled “2. Add Playwright MCP and scoped permissions”

Replace the session configuration with:

var workingDirectory = Directory.GetCurrentDirectory();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Model = selectedModel,
Streaming = true,
OnPermissionRequest = WorkshopPermissionHandler.CreateForTarget(targetUri),
Tools =
[
AccessibilityRuleCatalog.CreateLookupTool(),
PlaywrightSnapshotReader.CreateTool(workingDirectory)
],
AvailableTools =
[
"accessibility_rule_lookup",
"read_latest_accessibility_snapshot",
"playwright-browser_navigate"
],
McpServers = new Dictionary<string, McpServerConfig>
{
["playwright"] = new McpStdioServerConfig
{
Command = "npx",
Args = ["-y", "@playwright/mcp@0.0.78", "--browser=msedge"],
WorkingDirectory = workingDirectory,
Tools = ["browser_navigate"]
}
}
});

The browser argument uses Microsoft Edge, the workshop default. If you prepared Google Chrome instead, use --browser=chrome.

AvailableTools keeps unrelated runtime tools out of the session. The MCP server’s Tools list exposes only navigation. In Playwright MCP 0.0.78, navigation writes its automatic accessibility tree to .playwright-mcp/. The prebuilt PlaywrightSnapshotReader accepts no arguments and reads only the newest Playwright snapshot created after the session started.

browser_snapshot stays off both allowlists because its optional filename argument can write a file. The runtime can automatically allow MCP tools annotated as read-only without calling your permission delegate, so a handler cannot reliably sanitize that argument. Removing the tool closes the capability instead of relying on a prompt.

The reader accepts no path. It ignores pre-existing files, nested files, symbolic links, empty files, and snapshots larger than 1 MB. Navigation is approved only when the complete canonical URL matches the target supplied at startup. Scheme and host use URL-standard case-insensitive comparison. Path, query, and fragment must match case-sensitively.

SDK note: version 1.0.7 ships PermissionHandler.ApproveAll, but no built-in scoped handler. The starter therefore includes a hand-written delegate. PermissionDecision is currently marked evaluation-only, so that one helper contains a localized GHCP001 suppression.

Inspect the prebuilt permission handler

workshop-app/Helpers/WorkshopPermissionHandler.cs returns ApproveOnce only for exact-target navigation. Every other external request is rejected.

public static Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> CreateForTarget(
Uri allowedTarget)
{
return (request, _) =>
{
var decision = request switch
{
PermissionRequestMcp { ServerName: "playwright" } navigation
when IsPlaywrightTool(navigation, "browser_navigate") &&
IsNavigationToTarget(navigation.Args, allowedTarget) =>
PermissionDecision.ApproveOnce(),
_ => PermissionDecision.Reject(
"Only navigation to the exact target URL is allowed.")
};
return Task.FromResult(decision);
};
}

The SDK currently prefixes MCP permission tool names with the server name (for example, playwright-browser_navigate), while MCP configuration uses browser_navigate. IsPlaywrightTool accepts those two exact forms rather than using a broad wildcard.

Inspect the prebuilt snapshot-reader boundary

workshop-app/Helpers/PlaywrightSnapshotReader.cs captures the set of existing snapshots when the tool is created. Its tool callback accepts no model-supplied arguments, selects only a new direct child named page-*.yml, rejects symbolic links and oversized files, then returns the text.

The adapter uses SkipPermission = true because it is read-only, uses application-selected storage, and is implemented by the application. That is a narrower capability than a general file reader.

Replace the final send call:

Console.WriteLine($"\nInspecting: {targetUri.AbsoluteUri}\n");
await ResponseStreamer.SendAndPrintAsync(
session,
$"""
Use browser_navigate to open {targetUri.AbsoluteUri}.
Then use read_latest_accessibility_snapshot and report the page title
plus one sentence describing its main content.
""");
Terminal window
dotnet run --project workshop-app -- "https://jamesmontemagno.github.io/workshop-accessibility-agent/target-app/"

The first run may take longer while npx starts Playwright. Look for:

[tool:start] playwright-browser_navigate
[tool:done] success=True
[tool:start] read_latest_accessibility_snapshot
[tool:done] success=True
Page title: Blazor Accessibility Target
Troubleshooting this run
SymptomFix
npx cannot be startedRerun the preflight MCP command and verify Node.js is on PATH.
Playwright cannot find a browserInstall Edge or Chrome, or configure an installed browser as described by Playwright MCP.
A permission is rejectedUse the exact target URL above. The handler intentionally denies other URLs and tools.
No current-run snapshot is availableKeep the prompt order: call browser_navigate before read_latest_accessibility_snapshot.

You’re ready to combine tools when: the terminal shows named Playwright tool activity and prints the target page title.

Why is Playwright an MCP server here instead of another local C# callback?

Check your answer

Playwright provides reusable browser automation in its own process, with its own dependencies. MCP connects it without moving browser logic into the application’s domain code, and permissions protect the process boundary.

Complete Step 4 checkpoint

The Step 4 checkpoint contains the complete project: checkpoints/04-mcp-safety.

using GitHub.Copilot;
using HelloCopilotSDK.Helpers;
if (args.Length is not 1 ||
!Uri.TryCreate(args[0], UriKind.Absolute, out var targetUri) ||
targetUri.Scheme is not ("http" or "https"))
{
Console.Error.WriteLine("Usage: dotnet run --project workshop-app -- <http-or-https-url>");
return;
}
Console.WriteLine("=== Scoped Playwright MCP access ===\n");
await using var client = new CopilotClient();
await client.StartAsync();
var ping = await client.PingAsync("workshop");
Console.WriteLine($"Connected to the Copilot runtime: {ping.Message}\n");
var selectedModel = await ModelSelector.SelectAsync(client);
var workingDirectory = Directory.GetCurrentDirectory();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Model = selectedModel,
Streaming = true,
OnPermissionRequest = WorkshopPermissionHandler.CreateForTarget(targetUri),
Tools =
[
AccessibilityRuleCatalog.CreateLookupTool(),
PlaywrightSnapshotReader.CreateTool(workingDirectory)
],
AvailableTools =
[
"accessibility_rule_lookup",
"read_latest_accessibility_snapshot",
"playwright-browser_navigate"
],
McpServers = new Dictionary<string, McpServerConfig>
{
["playwright"] = new McpStdioServerConfig
{
Command = "npx",
Args = ["-y", "@playwright/mcp@0.0.78", "--browser=msedge"],
WorkingDirectory = workingDirectory,
Tools = ["browser_navigate"]
}
}
});
Console.WriteLine($"Inspecting: {targetUri.AbsoluteUri}\n");
await ResponseStreamer.SendAndPrintAsync(
session,
$"""
Use browser_navigate to open {targetUri.AbsoluteUri}.
Then use read_latest_accessibility_snapshot and report the page title
plus one sentence describing its main content.
""");

Continue to Step 5: Combine local and MCP tools.

Reset all workshop progress?

This clears completed lessons and checked task items stored in this browser. This action cannot be undone.