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
10 changes: 8 additions & 2 deletions packages/junior/src/api/mcp/oauth/page.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { escapeHtml, renderHtmlPage } from "@/api/html";

/** Render one MCP sign-in page. It sends no referrer and loads no resources. */
/**
* Render one MCP sign-in page. It loads no resources and sends no referrer to
* other origins.
*/
export function renderMcpPage(page: {
/** HTML that follows the message. Callers escape every value in it. */
bodyHtml?: string;
Expand All @@ -19,7 +22,10 @@ export function renderMcpPage(page: {
bodyHtml: page.bodyHtml ? `${message}\n${page.bodyHtml}` : message,
headers: {
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; form-action ${formAction.join(" ")}`,
"referrer-policy": "no-referrer",
// Not `no-referrer`: browsers then send `Origin: null` on the consent
// POST, which fails the same-origin check. The client redirect is
// cross-origin, so it still gets no referrer.
"referrer-policy": "same-origin",
},
status: page.status,
title: page.title,
Expand Down
24 changes: 20 additions & 4 deletions packages/junior/tests/fixtures/mcp-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,29 +111,44 @@ export async function openMcpConsentPage(
app: Hono,
consentUrl: string,
email: string,
): Promise<{ csrf?: string; html: string; status: number }> {
): Promise<{
csrf?: string;
html: string;
referrerPolicy: string | null;
status: number;
}> {
const response = await app.request(consentUrl, {
headers: { cookie: viewerCookie(email) },
});
const html = await response.text();
return {
csrf: html.match(/name="csrf" value="([^"]+)"/)?.[1],
html,
referrerPolicy: response.headers.get("referrer-policy"),
status: response.status,
};
}

/** Approve consent as the viewer who opened the page. */
/**
* Approve consent as the viewer who opened the page. The `Origin` header
* follows the page's referrer policy the way a browser form POST does: under
* `no-referrer`, browsers send `Origin: null`.
*/
export async function approveMcpConsent(
app: Hono,
args: { consentUrl: string; csrf: string; email: string },
args: {
consentUrl: string;
csrf: string;
email: string;
referrerPolicy: string | null;
},
): Promise<Response> {
return await app.request(args.consentUrl, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: viewerCookie(args.email),
origin: MCP_TEST_ORIGIN,
origin: args.referrerPolicy === "no-referrer" ? "null" : MCP_TEST_ORIGIN,
},
body: new URLSearchParams({ action: "approve", csrf: args.csrf }),
});
Expand Down Expand Up @@ -172,6 +187,7 @@ export async function approveMcpClient(
consentUrl,
csrf: page.csrf ?? "",
email,
referrerPolicy: page.referrerPolicy,
});
const code = new URL(approved.headers.get("location") ?? "").searchParams.get(
"code",
Expand Down
1 change: 1 addition & 0 deletions packages/junior/tests/integration/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("remote MCP HTTP", () => {
consentUrl,
csrf: page.csrf ?? "",
email: MCP_TEST_VIEWER,
referrerPolicy: page.referrerPolicy,
});
const callback = new URL(approved.headers.get("location") ?? "");
const issued = await exchangeMcpCode(app, {
Expand Down
Loading