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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/release-notes-0.2.90.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions src/auth/callback-page.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<script>x</script>" })).not.toContain(
"<script>x",
);
});

test("the page reaches for nothing off the machine", () => {
const html = callbackPageHtml({ subject: "linear" });
expect(html).not.toMatch(/https?:\/\/(?!www\.w3\.org)/);
});
});
283 changes: 283 additions & 0 deletions src/auth/callback-page.ts

Large diffs are not rendered by default.

10 changes: 3 additions & 7 deletions src/auth/oauth/callback-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createServer, type Server } from "node:http";
import { PRODUCT_NAME } from "../../branding.js";
import { callbackPageHtml } from "../callback-page.js";

export type CallbackServer = {
// Resolves with the validated authorization code once the browser redirects
Expand Down Expand Up @@ -111,10 +111,6 @@ export async function startCallbackServer(
};
}

export function authorizationDoneHtml(productName: string): string {
return (
"<!doctype html><meta charset=utf-8><title>Authorized</title>" +
'<body style="font-family:system-ui;padding:3rem;text-align:center">' +
`<h1>${productName} authorization complete</h1><p>You can close this tab and return to ${PRODUCT_NAME}.</p>`
);
export function authorizationDoneHtml(providerName: string): string {
return callbackPageHtml({ subject: providerName });
}
23 changes: 13 additions & 10 deletions src/mcp/callback-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { PRODUCT_NAME } from "../branding.js";
import { callbackPageHtml } from "../auth/callback-page.js";

export type CallbackServer = {
// The redirect_uri to register with the authorization server.
Expand All @@ -17,15 +17,12 @@ type CallbackWaiter = { resolve: (code: string) => void; reject: (error: Error)

const CALLBACK_PATH = "/callback";

const DONE_HTML =
"<!doctype html><meta charset=utf-8><title>Authorized</title>" +
"<body style=\"font-family:system-ui;padding:3rem;text-align:center\">" +
`<h1>Authorization complete</h1><p>You can close this tab and return to ${PRODUCT_NAME}.</p>`;

// Start an ephemeral loopback server to receive the OAuth redirect. Binds to a
// Start an ephemeral loopback server to receive the OAuth redirect. `serverName`
// only names the authorization on the page the browser lands on.
// Binds to a
// random port on 127.0.0.1 so it never collides with anything and is only
// reachable locally.
export async function startCallbackServer(): Promise<CallbackServer> {
export async function startCallbackServer(serverName?: string): Promise<CallbackServer> {
let expectedState: string | undefined;
let pendingResult: CallbackResult | undefined;
let waiter: CallbackWaiter | undefined;
Expand Down Expand Up @@ -61,9 +58,15 @@ export async function startCallbackServer(): Promise<CallbackServer> {

const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
res.statusCode = error !== null || code === null ? 400 : 200;
const failure = error ?? (code === null ? "the redirect carried no code" : undefined);
res.statusCode = failure === undefined ? 200 : 400;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(error !== null || code === null ? `Authorization failed: ${error ?? "no code returned"}` : DONE_HTML);
res.end(
callbackPageHtml({
...(serverName !== undefined ? { subject: serverName } : {}),
...(failure !== undefined ? { error: failure } : {}),
}),
);
if (error !== null) deliver({ error: new Error(`Authorization failed: ${error}`) });
else if (code === null) deliver({ error: new Error("Authorization redirect carried no code.") });
else deliver({ code });
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ async function connectStdio(config: MCPServerConfig, options: MCPConnectOptions)
async function connectHttp(config: MCPServerConfig, options: MCPConnectOptions): Promise<MCPConnectResult> {
if (config.url === undefined) return { ok: false, serverName: config.name, error: "http MCP server requires a url" };
const url = new URL(config.url);
const callback = await startCallbackServer();
const callback = await startCallbackServer(config.name);
const authProvider = await createOAuthProvider({
serverName: config.name,
redirectUrl: callback.redirectUrl,
Expand Down
56 changes: 56 additions & 0 deletions src/tui-opentui/command-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,62 @@ describe("hooks surface", () => {
})
})

describe("mcp surface", () => {
const entries = [
{ name: "linear", state: "connected" as const, toolCount: 12 },
{ name: "notion", state: "needs-auth" as const, authURL: "https://notion.test/auth" },
{ name: "sentry", state: "failed" as const, error: "ECONNREFUSED" },
]

test("lists every configured server with its live state", async () => {
await withShell((shell) => {
openCommandSurface(shell, "mcp", { notify: () => {}, mcp: { list: () => entries, openAuthURL: () => {} } })
expect(shell.overlayItems.slice(0, 3)).toEqual([
"linear — connected · 12 tools",
"notion — needs auth",
"sentry — failed",
])
})
})

test("Enter on an unauthorized server opens the browser and copies the link", async () => {
await withShell((shell) => {
const opened: string[] = []
openCommandSurface(shell, "mcp", {
notify: () => {},
mcp: { list: () => entries, openAuthURL: (url) => opened.push(url) },
})
moveOverlaySelection(shell, 1)
acceptOverlaySelection(shell)
expect(opened).toEqual(["https://notion.test/auth"])
expect(shell.statusFlash).toContain("notion")
// The echo would quote "notion — needs auth" back forever, moments
// after the operator authorized it.
expect(shell.streamLog.filter((r) => r.meta === "overlay")).toEqual([])
})
})

test("Enter on a connected server does nothing", async () => {
await withShell((shell) => {
const opened: string[] = []
openCommandSurface(shell, "mcp", {
notify: () => {},
mcp: { list: () => entries, openAuthURL: (url) => opened.push(url) },
})
acceptOverlaySelection(shell)
expect(opened).toEqual([])
})
})

test("reports the gap when the session has no mcp deps", async () => {
await withShell((shell) => {
const notes: string[] = []
openCommandSurface(shell, "mcp", { notify: (t) => notes.push(t) })
expect(notes[0]).toContain("not available")
})
})
})

describe("model surface", () => {
test("routes to the host picker, and reports the gap when absent", async () => {
await withShell((shell) => {
Expand Down
103 changes: 103 additions & 0 deletions src/tui-opentui/command-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
openHelpOverlay,
openListOverlay,
openSettingsOverlay,
setStatusFlash,
type AppShell,
type ItemDescription,
type OverlaySelection,
Expand Down Expand Up @@ -118,6 +119,24 @@ export type HooksSurfaceDeps = {
readonly setEnabled: (id: string, enabled: boolean) => Promise<void> | void
}

/** A configured MCP server and its live connection state. */
export type McpEntry = {
readonly name: string
readonly state: "connecting" | "connected" | "needs-auth" | "failed"
/** Tool count once connected. */
readonly toolCount?: number
/** Authorization URL while `needs-auth`. */
readonly authURL?: string
/** Failure reason while `failed`. */
readonly error?: string
}

export type McpSurfaceDeps = {
readonly list: () => readonly McpEntry[]
/** Open the server's authorization URL in the operator's browser. */
readonly openAuthURL: (url: string) => void
}

/** Live summary for the settings surface's hooks row (owned by another surface). */
export type HooksSurfaceSummary = {
readonly discovered: number
Expand All @@ -143,6 +162,7 @@ export type CommandSurfaceDeps = {
readonly permissions?: PermissionsSurfaceDeps
readonly plugins?: PluginsSurfaceDeps
readonly hooks?: HooksSurfaceDeps
readonly mcp?: McpSurfaceDeps
readonly settings?: SettingsSurfaceDeps
/** Opens the host's model/provider picker (owned by the product host). */
readonly openModels?: () => void
Expand All @@ -157,6 +177,7 @@ export type CommandSurfaceKind =
| "permissions"
| "plugins"
| "hooks"
| "mcp"
| "models"

const CLOSE_ID = "__close__"
Expand Down Expand Up @@ -882,6 +903,85 @@ export function openHooksSurface(shell: AppShell, deps: CommandSurfaceDeps): voi
})
}

export function mcpRowLabel(entry: McpEntry): string {
switch (entry.state) {
case "connecting":
return `${entry.name} — connecting`
case "connected": {
const n = entry.toolCount ?? 0
return `${entry.name} — connected · ${n} tool${n === 1 ? "" : "s"}`
}
case "needs-auth":
return `${entry.name} — needs auth`
case "failed":
return `${entry.name} — failed`
}
}

function mcpDescription(entry: McpEntry): ItemDescription {
switch (entry.state) {
case "connecting":
return { what: "Connecting — its tools are not dispatchable yet." }
case "connected":
return { what: "Connected. Its tools are reachable through tool_search." }
case "needs-auth":
return {
what: "Authorization has not completed, so this server contributes no tools.",
impact: "Enter opens the authorization page and copies the link.",
}
case "failed":
return { what: entry.error ?? "Did not connect.", tone: "consequence" }
}
}

/** Configured MCP servers and their live state; Enter authorizes an unauthorized one. */
export function openMcpSurface(shell: AppShell, deps: CommandSurfaceDeps): void {
const mcp = deps.mcp
if (mcp === undefined) {
deps.notify("MCP administration is not available in this session.")
return
}
closeInsetOverlay(shell)
const entries = mcp.list()
const rows: ResidualCatalogEntry[] = entries.map((e) => ({ id: e.name, label: mcpRowLabel(e) }))
if (rows.length === 0) {
rows.push({ id: CLOSE_ID, label: "No MCP servers configured" })
}
rows.push({ id: CLOSE_ID, label: "Close mcp" })
const byName = new Map(entries.map((e) => [e.name, e]))
openListOverlay(shell, {
kind: "mcp",
title: "mcp",
frameId: "overlay-mcp",
// The flash below reports the outcome; the echo would quote the row's
// pre-authorization label back at the operator forever.
echoChoice: false,
...payload(rows),
describe: (id) => {
const target = byName.get(id)
return target === undefined ? null : mcpDescription(target)
},
onAccept: (selection) => {
const id = selectedId(selection, rows)
if (id === undefined || id === CLOSE_ID) return
const target = byName.get(id)
const url = target?.authURL
if (target === undefined || target.state !== "needs-auth" || url === undefined) return
mcp.openAuthURL(url)
// The copy is the fallback that makes this work over SSH, where the
// browser that must receive the redirect is not on this machine.
void shell.clipboard.writeText(url)
closeInsetOverlay(shell)
setStatusFlash(shell, `opening ${target.name} authorization — link copied`, {
ttlMs: MCP_AUTH_FLASH_MS,
})
},
})
}

/** Long enough to notice the browser was asked to open, and why. */
const MCP_AUTH_FLASH_MS = 6000

function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
Expand Down Expand Up @@ -912,6 +1012,9 @@ export function openCommandSurface(
case "hooks":
openHooksSurface(shell, deps)
return true
case "mcp":
openMcpSurface(shell, deps)
return true
case "models":
if (deps.openModels === undefined) return false
deps.openModels()
Expand Down
Loading
Loading