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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<PackageVersion Include="Reqnroll.xUnit" Version="3.0.0" />
<PackageVersion Include="Reqnroll.Tools.MsBuild.Generation" Version="3.0.0" />
<PackageVersion Include="Handlebars.Net" Version="2.1.6" />
<PackageVersion Include="QRCoder" Version="1.6.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageVersion Include="Duende.IdentityServer" Version="7.4.7" />
Expand Down
1 change: 1 addition & 0 deletions src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<PackageReference Include="Microsoft.ML.OnnxRuntime" />
<PackageReference Include="HNSWIndex" />
<PackageReference Include="Handlebars.Net" />
<PackageReference Include="QRCoder" />
<ProjectReference Include="..\McpServer.ServiceDefaults\McpServer.ServiceDefaults.csproj" />
<ProjectReference Include="..\McpServer.Common.Copilot\McpServer.Common.Copilot.csproj" />
<ProjectReference Include="..\McpServer.Storage\McpServer.Storage.csproj" />
Expand Down
49 changes: 49 additions & 0 deletions src/McpServer.Support.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,55 @@ await pairingRenderer.RenderLoginPageAsync("Invalid username or password.").Conf
return Results.Content(await pairingRenderer.RenderKeyPageAsync(o.ApiKey, serverUrl).ConfigureAwait(false), "text/html");
}).ExcludeFromDescription();

app.MapGet("/pair/qr", async (HttpContext context, IOptions<PairingOptions> opts, PairingSessionService sessions,
TunnelRegistry tunnelRegistry, IOptions<IdentityServerOptions> idsOpts, IOptions<OidcAuthOptions> oidcOpts) =>
{
var token = context.Request.Cookies["mcp_pair"];
if (!sessions.Validate(token))
return Results.Redirect("/pair");

// Prefer the tunnel public URL so mobile devices can reach the server externally
string? baseUrl = null;
var tunnels = await tunnelRegistry.ListAsync(context.RequestAborted).ConfigureAwait(false);
var activeTunnel = tunnels.FirstOrDefault(t => t.IsRunning && !string.IsNullOrEmpty(t.PublicUrl));
if (activeTunnel is not null)
{
baseUrl = activeTunnel.PublicUrl!.TrimEnd('/');
}

baseUrl ??= $"{context.Request.Scheme}://{context.Request.Host}";

// When a tunnel is active, point to the identity server proxy login page
string loginUrl;
var ids = idsOpts.Value;
var oidc = oidcOpts.Value;
if (activeTunnel is not null && oidc.Enabled)
{
// External Keycloak: proxy login page through the tunnel
var authority = oidc.Authority.TrimEnd('/');
if (Uri.TryCreate(authority, UriKind.Absolute, out var authorityUri))
{
loginUrl = $"{baseUrl}/auth/ui{authorityUri.AbsolutePath}";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Append device path when building OIDC QR login URL

In the OIDC+tunnel branch, the QR code encodes .../auth/ui{authorityPath} (for example /auth/ui/realms/myrealm), which does not point to the device verification/login page used by this server’s OIDC proxy flow. The proxy logic elsewhere builds verification URLs as .../auth/ui{authorityPath}/device (see AuthConfigController), so scans in this configuration can land on a non-login endpoint (realm metadata or 404) and fail the intended mobile pairing/login path.

Useful? React with 👍 / 👎.

}
else
{
loginUrl = $"{baseUrl}/pair";
}
}
else if (activeTunnel is not null && ids.Enabled)
{
// Embedded IdentityServer: login page is served by the MCP server itself via tunnel
loginUrl = $"{baseUrl}/pair";
}
else
{
loginUrl = $"{baseUrl}/pair";
}

var svg = PairingQrCode.GenerateSvg(loginUrl);
return Results.Content(svg, "image/svg+xml");
}).ExcludeFromDescription();

// Seed IdentityServer defaults (admin user, roles) on first run
if (identityServerOptions is { Enabled: true, SeedDefaults: true })
{
Expand Down
289 changes: 148 additions & 141 deletions src/McpServer.Support.Mcp/Web/PairingHtml.cs
Original file line number Diff line number Diff line change
@@ -1,141 +1,148 @@
using System.Net;

namespace McpServer.Support.Mcp.Web;

/// <summary>
/// Inline HTML templates for the <c>/pair</c> web login flow.
/// Users authenticate with a configured username/password to view the server API key.
/// </summary>
internal static class PairingHtml
{
/// <summary>Renders the login form. Shows an error banner when <paramref name="errorMessage"/> is not empty.</summary>
public static string LoginPage(string? errorMessage = null)
{
var errorBanner = string.IsNullOrWhiteSpace(errorMessage)
? string.Empty
: $"<div style='background:#fee;color:#c00;padding:10px 16px;border-radius:6px;margin-bottom:16px;border:1px solid #fcc'>{WebUtility.HtmlEncode(errorMessage)}</div>";

return $$"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>MCP Server — Pair</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:32px;width:100%;max-width:380px}
h1{font-size:1.3rem;margin-bottom:4px;color:#111}
.sub{font-size:.85rem;color:#666;margin-bottom:20px}
label{display:block;font-size:.85rem;font-weight:600;margin-bottom:4px;color:#333}
input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:6px;font-size:.95rem;margin-bottom:14px}
input:focus{outline:none;border-color:#0969da;box-shadow:0 0 0 3px rgba(9,105,218,.15)}
button{width:100%;padding:10px;background:#0969da;color:#fff;border:none;border-radius:6px;font-size:.95rem;font-weight:600;cursor:pointer}
button:hover{background:#0860c4}
</style>
</head>
<body>
<div class="card">
<h1>🔗 MCP Server Pairing</h1>
<p class="sub">Sign in to view your API key.</p>
{{errorBanner}}
<form method="post" action="/pair">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus/>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required/>
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
""";
}

/// <summary>Renders the API key display page.</summary>
public static string KeyPage(string apiKey, string serverUrl)
{
return $$"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>MCP Server — API Key</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:32px;width:100%;max-width:480px}
h1{font-size:1.3rem;margin-bottom:4px;color:#111}
.sub{font-size:.85rem;color:#666;margin-bottom:20px}
.key-box{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:14px 16px;font-family:'Cascadia Code','Fira Code',monospace;font-size:.95rem;word-break:break-all;margin-bottom:16px;position:relative}
.copy-btn{position:absolute;top:8px;right:8px;background:#0969da;color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:.8rem;cursor:pointer}
.copy-btn:hover{background:#0860c4}
.section{margin-bottom:20px}
.section h2{font-size:1rem;margin-bottom:8px;color:#333}
pre{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:14px 16px;font-size:.82rem;overflow-x:auto;line-height:1.5}
.warn{font-size:.8rem;color:#888;margin-top:12px}
</style>
</head>
<body>
<div class="card">
<h1>🔑 Your API Key</h1>
<p class="sub">Use this key to authenticate mutating API calls.</p>
<div class="key-box">
<span id="key">{{apiKey}}</span>
<button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('key').textContent)">Copy</button>
</div>
<div class="section">
<h2>MCP Client Config</h2>
<pre>{
"mcpServers": {
"mcp-server": {
"url": "{{serverUrl}}/mcp-transport"
}
}
}</pre>
</div>
<div class="section">
<h2>cURL Example</h2>
<pre>curl {{serverUrl}}/mcpserver/workspace \
-H "X-Api-Key: {{apiKey}}"</pre>
</div>
<p class="warn">Keep this key secret. It grants write access to workspace and tool endpoints.</p>
</div>
</body>
</html>
""";
}

/// <summary>Renders a page shown when pairing is not configured.</summary>
public static string NotConfiguredPage()
{
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>MCP Server — Pairing Not Configured</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:32px;width:100%;max-width:380px;text-align:center}
h1{font-size:1.3rem;margin-bottom:12px;color:#111}
p{color:#666;font-size:.9rem;line-height:1.5}
code{background:#f6f8fa;padding:2px 6px;border-radius:4px;font-size:.85rem}
</style>
</head>
<body>
<div class="card">
<h1>⚠️ Pairing Not Configured</h1>
<p>To enable the pairing page, add one or more users to
<code>Mcp:PairingUsers</code> in your configuration and set
<code>Mcp:ApiKey</code> to a non-empty value.</p>
</div>
</body>
</html>
""";
}
}
using System.Net;

namespace McpServer.Support.Mcp.Web;

/// <summary>
/// Inline HTML templates for the <c>/pair</c> web login flow.
/// Users authenticate with a configured username/password to view the server API key.
/// </summary>
internal static class PairingHtml
{
/// <summary>Renders the login form. Shows an error banner when <paramref name="errorMessage"/> is not empty.</summary>
public static string LoginPage(string? errorMessage = null)
{
var errorBanner = string.IsNullOrWhiteSpace(errorMessage)
? string.Empty
: $"<div style='background:#fee;color:#c00;padding:10px 16px;border-radius:6px;margin-bottom:16px;border:1px solid #fcc'>{WebUtility.HtmlEncode(errorMessage)}</div>";

return $$"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>MCP Server — Pair</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:32px;width:100%;max-width:380px}
h1{font-size:1.3rem;margin-bottom:4px;color:#111}
.sub{font-size:.85rem;color:#666;margin-bottom:20px}
label{display:block;font-size:.85rem;font-weight:600;margin-bottom:4px;color:#333}
input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:6px;font-size:.95rem;margin-bottom:14px}
input:focus{outline:none;border-color:#0969da;box-shadow:0 0 0 3px rgba(9,105,218,.15)}
button{width:100%;padding:10px;background:#0969da;color:#fff;border:none;border-radius:6px;font-size:.95rem;font-weight:600;cursor:pointer}
button:hover{background:#0860c4}
</style>
</head>
<body>
<div class="card">
<h1>🔗 MCP Server Pairing</h1>
<p class="sub">Sign in to view your API key.</p>
{{errorBanner}}
<form method="post" action="/pair">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus/>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required/>
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
""";
}

/// <summary>Renders the API key display page.</summary>
public static string KeyPage(string apiKey, string serverUrl)
{
return $$"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>MCP Server — API Key</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:32px;width:100%;max-width:480px}
h1{font-size:1.3rem;margin-bottom:4px;color:#111}
.sub{font-size:.85rem;color:#666;margin-bottom:20px}
.key-box{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:14px 16px;font-family:'Cascadia Code','Fira Code',monospace;font-size:.95rem;word-break:break-all;margin-bottom:16px;position:relative}
.copy-btn{position:absolute;top:8px;right:8px;background:#0969da;color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:.8rem;cursor:pointer}
.copy-btn:hover{background:#0860c4}
.qr-section{text-align:center;margin-bottom:20px}
.qr-section h2{font-size:1rem;margin-bottom:12px;color:#333}
.qr-section img{display:inline-block;padding:12px;background:#fff;border:1px solid #d0d7de;border-radius:8px;width:256px;height:256px}
.section{margin-bottom:20px}
.section h2{font-size:1rem;margin-bottom:8px;color:#333}
pre{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:14px 16px;font-size:.82rem;overflow-x:auto;line-height:1.5}
.warn{font-size:.8rem;color:#888;margin-top:12px}
</style>
</head>
<body>
<div class="card">
<h1>🔑 Your API Key</h1>
<p class="sub">Scan the QR code with the MCP Server mobile app, or copy the key below.</p>
<div class="qr-section">
<h2>📱 Scan to Pair</h2>
<img src="/pair/qr" alt="QR code for pairing"/>
</div>
<div class="key-box">
<span id="key">{{apiKey}}</span>
<button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('key').textContent)">Copy</button>
</div>
<div class="section">
<h2>MCP Client Config</h2>
<pre>{
"mcpServers": {
"mcp-server": {
"url": "{{serverUrl}}/mcp-transport"
}
}
}</pre>
</div>
<div class="section">
<h2>cURL Example</h2>
<pre>curl {{serverUrl}}/mcpserver/workspace \
-H "X-Api-Key: {{apiKey}}"</pre>
</div>
<p class="warn">Keep this key secret. It grants write access to workspace and tool endpoints.</p>
</div>
</body>
</html>
""";
}

/// <summary>Renders a page shown when pairing is not configured.</summary>
public static string NotConfiguredPage()
{
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>MCP Server — Pairing Not Configured</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:32px;width:100%;max-width:380px;text-align:center}
h1{font-size:1.3rem;margin-bottom:12px;color:#111}
p{color:#666;font-size:.9rem;line-height:1.5}
code{background:#f6f8fa;padding:2px 6px;border-radius:4px;font-size:.85rem}
</style>
</head>
<body>
<div class="card">
<h1>⚠️ Pairing Not Configured</h1>
<p>To enable the pairing page, add one or more users to
<code>Mcp:PairingUsers</code> in your configuration and set
<code>Mcp:ApiKey</code> to a non-empty value.</p>
</div>
</body>
</html>
""";
}
}
18 changes: 18 additions & 0 deletions src/McpServer.Support.Mcp/Web/PairingQrCode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using QRCoder;

namespace McpServer.Support.Mcp.Web;

/// <summary>
/// Generates QR code SVG images for the pairing flow.
/// </summary>
internal static class PairingQrCode
{
/// <summary>Generates an SVG string containing a QR code for the given <paramref name="text"/>.</summary>
public static string GenerateSvg(string text)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
using var svg = new SvgQRCode(data);
return svg.GetGraphic(8);
}
}
Loading
Loading