Skip to content

Add a friendly browser landing page for the MCP endpoint - #1185

Merged
kody-bot merged 1 commit into
mainfrom
cursor/mcp-browser-landing-9dde
Aug 3, 2026
Merged

kody-bot merged 1 commit into
mainfrom
cursor/mcp-browser-landing-9dde

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Aug 3, 2026 •

Copy link
Copy Markdown
Owner

Intent

Prevent non-technical users from seeing a raw OAuth error when they paste Kody's MCP URL into a browser, without changing MCP protocol behavior.

Summary

  • Serve a small Kody-branded HTML explanation for unauthenticated browser GET navigations to /mcp.
  • Link to the request origin's /onboarding guide.
  • Preserve the existing JSON OAuth challenge for SSE, JSON-RPC POST, and token-bearing requests.

Testing

  • npm run validate — passed (1,920 unit/Workers tests, 3 MCP tests, E2E, lint, format, types, build, and repository checks).
  • npm run test:workers -- packages/worker/src/mcp-auth.workers.test.ts — 5 tests passed.
  • Ready-for-review CI — all Static, Node, Workers, MCP, E2E, Validate, preview, and CodeRabbit checks passed.
  • Local dev-server curl:
$ curl -i -H 'Accept: text/html' http://localhost:3742/mcp
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-store

...<a href="http://localhost:3742/onboarding">Follow the guide to connect your agent</a>...

$ curl -i -H 'Accept: text/event-stream' http://localhost:3742/mcp
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer resource_metadata="http://localhost:3742/.well-known/oauth-protected-resource" scope="profile email"

{"error":"invalid_token","error_description":"Authentication required. Obtain an access token via OAuth and retry with Authorization: Bearer."}

$ curl -i -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"initialize"}' http://localhost:3742/mcp
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer resource_metadata="http://localhost:3742/.well-known/oauth-protected-resource" scope="profile email"

{"error":"invalid_token","error_description":"Authentication required. Obtain an access token via OAuth and retry with Authorization: Bearer."}

mcp_browser_landing_walkthrough.mp4

System changes

System recap — extends an existing primitive (medium risk)

Mode: recap · Base: main @ a4c25471 · Head: 84f2baf8

Classification: extends — this changes the unauthenticated browser response at the OAuth-protected MCP entrypoint while preserving protocol challenges.

Primitives touched

Primitive Group Impact
mcp-oauth auth extends — HTML response for anonymous browser navigation only

System map

Browser GETs receive guidance while MCP transport requests continue through the existing OAuth challenge path.

Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).

flowchart LR
	browser["Browser navigation"]:::untouched
	mcpOAuth["mcp-oauth<br/>MCP OAuth"]:::extended
	mcpServer["mcp-server<br/>MCP endpoint (/mcp)"]:::untouched
	onboarding["app-ui<br/>Browser app (Remix 3)"]:::untouched
	browser -->|"GET /mcp, Accept: text/html"| mcpOAuth
	mcpOAuth -->|"200 HTML link"| onboarding
	mcpServer -->|"SSE, JSON-RPC, bearer requests unchanged"| mcpOAuth
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Before / after

Request Before After
Anonymous browser GET 401 JSON OAuth challenge 200 HTML guidance
SSE / JSON-RPC / token-bearing request Existing OAuth/MCP behavior Unchanged

Conductor report

Status: merged and verified, but production rollout is blocked by a pre-existing cross-track migration gate.

Evidence: PR #1185 squash-merged as 89ac5513; local authoritative gate and all ready-for-review/post-merge CI passed; curl and browser walkthrough prove the HTML/protocol split.

What remains: production deploy run 30859923182 failed before Worker upload because migration 0135-drop-legacy-email-graph.sql from PR #1174 correctly rejected missing/mismatched destructive-drop approval data (CHECK constraint failed: value = 1). Production remains on the previous build until Track Email's fresh backup/approval procedure succeeds and deploy is rerun.

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added a browser-friendly onboarding page for unauthenticated visits to the MCP endpoint.
    • The page guides visitors to /onboarding without displaying an authentication challenge.
  • Bug Fixes

    • Preserved consistent JSON authentication errors for API, streaming, and token-authenticated requests.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The MCP endpoint now detects unauthenticated browser navigation requests and returns an onboarding page. Protocol requests continue through authentication and return consistent JSON 401 responses. Tests cover browser, SSE, JSON-RPC, and bearer-token request behavior.

Changes

MCP browser guidance

Layer / File(s) Summary
Browser navigation landing response
packages/worker/src/mcp-auth.ts
The handler identifies unauthenticated HTML-preferring GET requests and returns a no-store HTML response with an /onboarding link.
Protocol authentication regression coverage
packages/worker/src/mcp-auth.workers.test.ts
Tests verify browser onboarding responses and 401 invalid_token responses with WWW-Authenticate headers for protocol requests.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • kentcdodds/kody#1106: Extends the onboarding flow with Grok MCP client guidance linked by this change.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding a friendly browser landing page for the MCP endpoint.
Description check ✅ Passed The description includes complete Intent, Summary, Testing, and System changes sections with relevant implementation and verification details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/mcp-browser-landing-9dde

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kody-bot
kody-bot marked this pull request as ready for review August 3, 2026 22:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/worker/src/mcp-auth.workers.test.ts (1)

372-380: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the no-store response contract.

packages/worker/src/mcp-auth.ts sets Cache-Control: no-store, but this test does not verify it. Add a header assertion so a future change cannot make the browser landing page cacheable.

Proposed fix
 	expect(browserResponse.headers.get('Content-Type')).toBe(
 		'text/html; charset=utf-8',
 	)
+	expect(browserResponse.headers.get('Cache-Control')).toBe('no-store')
 	expect(await browserResponse.text()).toContain(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/mcp-auth.workers.test.ts` around lines 372 - 380, Add an
assertion in the browser landing-page response test alongside the existing
header checks to verify the Cache-Control header is exactly no-store, preserving
the contract established by the MCP authentication response.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/worker/src/mcp-auth.ts`:
- Around line 88-110: Update acceptsMediaType and isBrowserMcpNavigation to
parse and compare quality values for text/html, application/json, and
text/event-stream rather than rejecting based on mere presence. Return true only
when HTML has a positive quality greater than both protocol media types,
treating omitted quality as 1 and q=0 as unacceptable; add regression coverage
for weighted preferences and q=0 JSON/SSE values.

---

Nitpick comments:
In `@packages/worker/src/mcp-auth.workers.test.ts`:
- Around line 372-380: Add an assertion in the browser landing-page response
test alongside the existing header checks to verify the Cache-Control header is
exactly no-store, preserving the contract established by the MCP authentication
response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d72f51c-9a2e-4d9a-8777-174ad9578ef2

📥 Commits

Reviewing files that changed from the base of the PR and between a4c2547 and 84f2baf.

📒 Files selected for processing (2)
  • packages/worker/src/mcp-auth.ts
  • packages/worker/src/mcp-auth.workers.test.ts

Comment on lines +88 to +110
function acceptsMediaType(accept: string, mediaType: string) {
return accept.split(',').some((range) => {
const [type, ...parameters] = range.trim().split(';')
if (type?.trim() !== mediaType) return false
const quality = parameters
.map((parameter) => parameter.trim())
.find((parameter) => parameter.startsWith('q='))
return quality ? Number(quality.slice(2)) > 0 : true
})
}

function isBrowserMcpNavigation(request: Request) {
if (request.method !== 'GET' || request.headers.has('Authorization')) {
return false
}
const accept = request.headers.get('Accept')?.toLowerCase()
if (!accept) return false
return (
acceptsMediaType(accept, 'text/html') &&
!accept.includes('text/event-stream') &&
!accept.includes('application/json')
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compare Accept quality values before rejecting browser navigation.

Lines 107-108 reject any request that includes JSON or SSE. A request with text/html;q=1, application/json;q=0.1 prefers HTML but receives a JSON 401. This conflicts with the browser-navigation contract.

Parse the quality value for each media type. Return the landing page only when the HTML quality is greater than the JSON and SSE qualities. Add regression cases for weighted and q=0 protocol media types.

Proposed fix
-function acceptsMediaType(accept: string, mediaType: string) {
-	return accept.split(',').some((range) => {
+function mediaTypeQuality(accept: string, mediaType: string) {
+	return accept.split(',').reduce((highest, range) => {
 		const [type, ...parameters] = range.trim().split(';')
-		if (type?.trim() !== mediaType) return false
-		const quality = parameters
+		const qualityParameter = parameters
 			.map((parameter) => parameter.trim())
 			.find((parameter) => parameter.startsWith('q='))
-		return quality ? Number(quality.slice(2)) > 0 : true
-	})
+		const quality = qualityParameter
+			? Number(qualityParameter.slice(2))
+			: 1
+		if (
+			type?.trim() !== mediaType ||
+			!Number.isFinite(quality) ||
+			quality < 0 ||
+			quality > 1
+		) {
+			return highest
+		}
+		return Math.max(highest, quality)
+	}, 0)
 }
 
 function isBrowserMcpNavigation(request: Request) {
+	// ...
+	const htmlQuality = mediaTypeQuality(accept, 'text/html')
 	return (
-		acceptsMediaType(accept, 'text/html') &&
-		!accept.includes('text/event-stream') &&
-		!accept.includes('application/json')
+		htmlQuality > 0 &&
+		htmlQuality > mediaTypeQuality(accept, 'text/event-stream') &&
+		htmlQuality > mediaTypeQuality(accept, 'application/json')
 	)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function acceptsMediaType(accept: string, mediaType: string) {
return accept.split(',').some((range) => {
const [type, ...parameters] = range.trim().split(';')
if (type?.trim() !== mediaType) return false
const quality = parameters
.map((parameter) => parameter.trim())
.find((parameter) => parameter.startsWith('q='))
return quality ? Number(quality.slice(2)) > 0 : true
})
}
function isBrowserMcpNavigation(request: Request) {
if (request.method !== 'GET' || request.headers.has('Authorization')) {
return false
}
const accept = request.headers.get('Accept')?.toLowerCase()
if (!accept) return false
return (
acceptsMediaType(accept, 'text/html') &&
!accept.includes('text/event-stream') &&
!accept.includes('application/json')
)
}
function mediaTypeQuality(accept: string, mediaType: string) {
return accept.split(',').reduce((highest, range) => {
const [type, ...parameters] = range.trim().split(';')
const qualityParameter = parameters
.map((parameter) => parameter.trim())
.find((parameter) => parameter.startsWith('q='))
const quality = qualityParameter
? Number(qualityParameter.slice(2))
: 1
if (
type?.trim() !== mediaType ||
!Number.isFinite(quality) ||
quality < 0 ||
quality > 1
) {
return highest
}
return Math.max(highest, quality)
}, 0)
}
function isBrowserMcpNavigation(request: Request) {
if (request.method !== 'GET' || request.headers.has('Authorization')) {
return false
}
const accept = request.headers.get('Accept')?.toLowerCase()
if (!accept) return false
const htmlQuality = mediaTypeQuality(accept, 'text/html')
return (
htmlQuality > 0 &&
htmlQuality > mediaTypeQuality(accept, 'text/event-stream') &&
htmlQuality > mediaTypeQuality(accept, 'application/json')
)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/mcp-auth.ts` around lines 88 - 110, Update
acceptsMediaType and isBrowserMcpNavigation to parse and compare quality values
for text/html, application/json, and text/event-stream rather than rejecting
based on mere presence. Return true only when HTML has a positive quality
greater than both protocol media types, treating omitted quality as 1 and q=0 as
unacceptable; add regression coverage for weighted preferences and q=0 JSON/SSE
values.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-1185.kody-a99.workers.dev

Worker: kody-pr-1185
D1: kody-pr-1185-db
KV: kody-pr-1185-oauth-kv

Mocks:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants