diff --git a/CHANGELOG.md b/CHANGELOG.md
index 822ac2545..8193d9dc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,26 @@ unchanged: Ctrl+C interrupts, twice exits.
- **Resumed sessions dropped `view`, `plan` and `tasks` blocks** silently.
- **Ctrl+D quit mid-edit.** The host claims no key of its own now.
+### MCP
+
+- **Authorization moved out of the transcript and into `/mcp`.** A server
+ needing OAuth used to dump a raw authorization URL as a transcript row at
+ session start — unactionable, uncopyable, and gone once it scrolled away. The
+ notice row now names the servers waiting (`mcp granola needs auth (/mcp)`)
+ and clears when they connect; nothing blocks usage, an unauthorized server
+ simply has no tools. (CL-5555)
+- **`/mcp` is a real surface** listing every configured server and its live
+ state — connected with tool count, needs auth, or failed with the reason.
+ Enter on an unauthorized row opens its authorization page in the browser and
+ copies the link, so the flow also works over SSH. (CL-5555)
+- **The OAuth callback page carries the brand.** One page now serves MCP
+ servers and inference providers alike, on the terminal's own palette, with
+ the mark animating through the same dithered draw/fill timeline as the
+ landing. It names what happened — "Linear connected successfully", "Granola
+ failed to connect" — and humanizes server names and error codes on the way
+ in. Entirely inline: a local authorization callback makes no network call.
+ (CL-5556)
+
### Permissions
- **Shell-block messaging** cites host safety and OOM risk, and names the
diff --git a/docs/release-notes-0.2.90.md b/docs/release-notes-0.2.90.md
index 0e0935ad8..7b8107859 100644
--- a/docs/release-notes-0.2.90.md
+++ b/docs/release-notes-0.2.90.md
@@ -26,6 +26,20 @@ rollback is the prior tag rather than a setting.
- Live status for lifecycle hooks, subagent progress, MCP connections and
recorded permission grants.
+### Connecting an MCP server
+
+Remote MCP servers that need OAuth used to print a raw authorization URL into
+the transcript the moment you started a session — nothing you could click, copy
+or come back to.
+
+- Nothing blocks usage. A server waiting on authorization simply has no tools,
+ and the notice row names it: `mcp granola needs auth (/mcp)`.
+- **`/mcp` lists every server** and its live state. Enter on one that needs
+ authorization opens the page in your browser and copies the link, so it works
+ over SSH too.
+- **The page your browser lands on** now tells you which server connected, and
+ looks like the rest of Corbits rather than a browser default.
+
### Fixed
The ones most likely to have affected you:
diff --git a/src/auth/callback-page.test.ts b/src/auth/callback-page.test.ts
new file mode 100644
index 000000000..695ab46f4
--- /dev/null
+++ b/src/auth/callback-page.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "bun:test";
+
+import { callbackPageHtml, humanizeIdentifier } from "./callback-page.js";
+
+describe("humanizeIdentifier", () => {
+ test("machine identifiers lose their separators and lead with a capital", () => {
+ expect(humanizeIdentifier("access_denied")).toBe("Access denied");
+ expect(humanizeIdentifier("granola")).toBe("Granola");
+ expect(humanizeIdentifier("claude-ai-gamma")).toBe("Claude ai gamma");
+ expect(humanizeIdentifier("googleDrive")).toBe("Google Drive");
+ });
+
+ test("an empty identifier is returned untouched rather than as a stray capital", () => {
+ expect(humanizeIdentifier("")).toBe("");
+ });
+});
+
+describe("callbackPageHtml", () => {
+ test("success names the server that connected", () => {
+ const html = callbackPageHtml({ subject: "linear" });
+ expect(html).toContain("Linear connected successfully");
+ expect(html).not.toContain("access_denied");
+ });
+
+ test("failure names the server and the humanized reason", () => {
+ const html = callbackPageHtml({ subject: "granola", error: "access_denied" });
+ expect(html).toContain("Granola failed to connect");
+ expect(html).toContain("Access denied.");
+ expect(html).not.toContain("access_denied");
+ });
+
+ test("an unnamed authorization still renders both outcomes", () => {
+ expect(callbackPageHtml()).toContain("Authorization complete");
+ expect(callbackPageHtml({ error: "server_error" })).toContain(
+ "Authorization did not complete",
+ );
+ });
+
+ test("the subject is escaped rather than pasted into markup", () => {
+ expect(callbackPageHtml({ subject: "" })).not.toContain(
+ "`,
+ "