From 2d21b781a7fec67559805480cf6da0ce329dc482 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 02:02:25 -0400 Subject: [PATCH 1/2] feat(tui): copy values out of the TUI via OSC 52, with a file fallback The TUI had no copy affordance at all (#2421). Details views (Resources, Prompts, Tools, Protocol, Network) and the Auth tab's access token now take two keys: - Y sends the value to the terminal clipboard as an OSC 52 escape sequence, which reaches the local clipboard even over SSH. - W saves it to an owner-only temp file and shows the path, the fallback for a terminal that ignores OSC 52 (which fails silently). No dependency is added; OSC 52 is a plain escape sequence. The README documents terminal support, the tmux setting, and the payload-size caveat. Closes #2421 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/README.md | 9 ++ clients/tui/__tests__/AuthTab.test.tsx | 40 ++++++ clients/tui/__tests__/DetailsModal.test.tsx | 51 ++++++- clients/tui/__tests__/clipboard.test.ts | 86 ++++++++++++ clients/tui/__tests__/useCopyKeys.test.tsx | 141 ++++++++++++++++++++ clients/tui/src/App.tsx | 9 ++ clients/tui/src/components/AuthTab.tsx | 19 ++- clients/tui/src/components/DetailsModal.tsx | 27 +++- clients/tui/src/hooks/useCopyKeys.ts | 80 +++++++++++ clients/tui/src/utils/clipboard.ts | 99 ++++++++++++++ 10 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 clients/tui/__tests__/clipboard.test.ts create mode 100644 clients/tui/__tests__/useCopyKeys.test.tsx create mode 100644 clients/tui/src/hooks/useCopyKeys.ts create mode 100644 clients/tui/src/utils/clipboard.ts diff --git a/clients/tui/README.md b/clients/tui/README.md index 8f28871bf4..3f04d49e9f 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -92,6 +92,15 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se - Press **Enter** to select an item, execute a tool, or fetch a resource. - Press **Escape** or `Ctrl+C` to exit the application. +## Copying values + +Any details view (opened from Resources, Prompts, Tools, Protocol or Network) and the **Auth** tab's access token can be copied out of the TUI ([#2421](https://github.com/modelcontextprotocol/inspector/issues/2421)): + +- **Y** copies the value to your clipboard with an [OSC 52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) escape sequence. The sequence asks the _terminal emulator_ to set its clipboard, so it reaches the machine you are sitting at even when the TUI runs over SSH. A details view copies the entry's raw JSON; the Auth tab copies the full token, not the truncated prefix it displays. +- **W** saves the value to a private temp file (`mcp-inspector-copy-*/value.txt`, mode `0600`) and shows its path — the fallback when OSC 52 does not work. + +OSC 52 is fire-and-forget: a terminal that does not support it (or has it disabled) fails silently, which is why the status line after **Y** points at **W**. Most modern terminals support it — iTerm2 (enable _Applications in terminal may access clipboard_), kitty, WezTerm, Alacritty, Windows Terminal, foot, and xterm with `allowWindowOps`. Inside **tmux**, set `set -g set-clipboard on` so tmux forwards the sequence. Some terminals cap the payload (often around 100 KB); a copy above that is flagged in the status line, and **W** has no such limit. + ## Development Like the other clients, the TUI self-validates from its own folder: diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index 4f462e48ab..67937f60d6 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -7,6 +7,7 @@ import type { InspectorClient } from "@inspector/core/mcp/index.js"; vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); import { AuthTab } from "../src/components/AuthTab.js"; +import { buildOsc52Sequence } from "../src/utils/clipboard.js"; const tick = async () => { for (let i = 0; i < 8; i++) @@ -623,4 +624,43 @@ describe("AuthTab", () => { unmount(); expect(listeners.get("oauthComplete")?.size).toBe(0); }); + + it("Y copies the full access token via OSC 52 (#2421)", async () => { + const { client } = makeClient(sampleOAuthState); + const { stdin, stdout, lastFrame } = render( + , + ); + await tick(); + expect(lastFrame()).toContain("Y copy token, W save token"); + stdin.write("Y"); + await tick(); + expect(stdout.frames).toContain( + buildOsc52Sequence("tok-abcdefghijklmnopqrstuvwxyz"), + ); + expect(lastFrame()).toContain("Copied access token (30 chars)"); + }); + + it("offers no token copy when there is no access token", async () => { + const { client } = makeClient(undefined); + const { stdin, stdout, lastFrame } = render( + , + ); + await tick(); + expect(lastFrame()).not.toContain("Y copy token"); + stdin.write("y"); + await tick(); + expect(stdout.frames.some((f) => f.includes("\u001b]52;"))).toBe(false); + }); }); diff --git a/clients/tui/__tests__/DetailsModal.test.tsx b/clients/tui/__tests__/DetailsModal.test.tsx index 655041a4d4..6fe28444d1 100644 --- a/clients/tui/__tests__/DetailsModal.test.tsx +++ b/clients/tui/__tests__/DetailsModal.test.tsx @@ -1,13 +1,14 @@ import React from "react"; import { describe, it, expect, vi } from "vitest"; import { render } from "./helpers/renderTui"; -import { Text } from "ink"; +import { Box, Text } from "ink"; // ScrollView: passthrough so `content` mounts and the imperative ref API // (scrollBy / getViewportHeight) exists for the scroll-key handlers. vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); import { DetailsModal } from "../src/components/DetailsModal.js"; +import { buildOsc52Sequence } from "../src/utils/clipboard.js"; // Ink processes stdin keypresses asynchronously — await this after stdin.write. const tick = async () => { @@ -99,4 +100,52 @@ describe("DetailsModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + + it("Y copies copyText via OSC 52 and shows the status line (#2421)", async () => { + const onClose = vi.fn(); + // Wrapped in a sized box: the modal is position="absolute", so on its own + // it contributes nothing to the frame and the status line can't be read. + const { stdin, stdout, lastFrame } = render( + + x} + width={120} + height={30} + onClose={onClose} + copyText='{"raw":true}' + /> + , + ); + + await tick(); + expect(lastFrame()).toContain("Y to copy, W to save to a file"); + stdin.write("y"); + await tick(); + + expect(stdout.frames).toContain(buildOsc52Sequence('{"raw":true}')); + expect(lastFrame()).toContain("Copied details (12 chars)"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("offers no copy affordance without copyText", async () => { + const { stdin, stdout, lastFrame } = render( + + x} + width={120} + height={30} + onClose={() => {}} + /> + , + ); + + await tick(); + stdin.write("y"); + await tick(); + + expect(lastFrame()).not.toContain("Y to copy"); + expect(stdout.frames.some((f) => f.includes("\u001b]52;"))).toBe(false); + }); }); diff --git a/clients/tui/__tests__/clipboard.test.ts b/clients/tui/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..965070f72a --- /dev/null +++ b/clients/tui/__tests__/clipboard.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + OSC52_LARGE_PAYLOAD_BYTES, + buildOsc52Sequence, + copyViaOsc52, + toCopyText, + writeCopyFile, +} from "../src/utils/clipboard.js"; + +describe("toCopyText", () => { + it("passes strings through verbatim", () => { + expect(toCopyText("raw value")).toBe("raw value"); + }); + + it("pretty-prints anything else as JSON", () => { + expect(toCopyText({ a: 1, b: [true] })).toBe( + JSON.stringify({ a: 1, b: [true] }, null, 2), + ); + }); + + it("falls back to String() when JSON has no representation", () => { + expect(toCopyText(undefined)).toBe("undefined"); + }); + + it("falls back to String() when JSON.stringify throws", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(toCopyText(cyclic)).toBe("[object Object]"); + expect(toCopyText(10n)).toBe("10"); + }); +}); + +describe("buildOsc52Sequence", () => { + it("frames the base64 UTF-8 payload as OSC 52 for the clipboard", () => { + const seq = buildOsc52Sequence("héllo"); + expect(seq).toBe( + `\u001b]52;c;${Buffer.from("héllo", "utf8").toString("base64")}\u0007`, + ); + }); +}); + +describe("copyViaOsc52", () => { + it("writes the sequence to the sink and reports its size", () => { + const written: string[] = []; + const result = copyViaOsc52("abc", { write: (s) => written.push(s) }); + expect(written).toEqual([buildOsc52Sequence("abc")]); + expect(result).toEqual({ chars: 3, payloadBytes: 4, large: false }); + }); + + it("flags a payload over the large-payload threshold", () => { + const text = "x".repeat(OSC52_LARGE_PAYLOAD_BYTES); + const result = copyViaOsc52(text, { write: () => undefined }); + expect(result.large).toBe(true); + expect(result.payloadBytes).toBeGreaterThan(OSC52_LARGE_PAYLOAD_BYTES); + }); +}); + +describe("writeCopyFile", () => { + const created: string[] = []; + afterEach(() => { + for (const dir of created.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("saves the text to an owner-only file in a fresh temp directory", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clip-test-")); + created.push(base); + const file = writeCopyFile("secret-token", base); + expect(path.dirname(path.dirname(file))).toBe(base); + expect(fs.readFileSync(file, "utf8")).toBe("secret-token"); + if (process.platform !== "win32") { + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(file)).mode & 0o777).toBe(0o700); + } + }); + + it("defaults to the OS temp directory", () => { + const file = writeCopyFile("v"); + created.push(path.dirname(file)); + expect(file.startsWith(os.tmpdir())).toBe(true); + }); +}); diff --git a/clients/tui/__tests__/useCopyKeys.test.tsx b/clients/tui/__tests__/useCopyKeys.test.tsx new file mode 100644 index 0000000000..12b39d503d --- /dev/null +++ b/clients/tui/__tests__/useCopyKeys.test.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Text, useInput } from "ink"; +import { render } from "./helpers/renderTui"; +import { + COPY_KEY, + SAVE_KEY, + copyStatusColor, + useCopyKeys, +} from "../src/hooks/useCopyKeys.js"; +import { + OSC52_LARGE_PAYLOAD_BYTES, + buildOsc52Sequence, +} from "../src/utils/clipboard.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +/** Renders the hook's status and records whether each key was consumed. */ +function Harness({ + value, + consumed, +}: { + value: string | undefined; + consumed: boolean[]; +}) { + const { handleCopyKey, status } = useCopyKeys(value, "thing"); + useInput((input) => { + consumed.push(handleCopyKey(input)); + }); + return ( + {status ? `[${status.tone}] ${status.message}` : "no status"} + ); +} + +const savedPaths: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const file of savedPaths.splice(0)) { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } +}); + +describe("useCopyKeys", () => { + it("Y emits OSC 52 to stdout and reports success", async () => { + const consumed: boolean[] = []; + const { stdin, stdout, lastFrame } = render( + , + ); + await tick(); + stdin.write(COPY_KEY.toUpperCase()); + await tick(); + expect(stdout.frames).toContain(buildOsc52Sequence("hello")); + expect(lastFrame()).toContain("[success] Copied thing (5 chars) via OSC"); + expect(consumed).toEqual([true]); + }); + + it("warns when the payload is large", async () => { + const big = "x".repeat(OSC52_LARGE_PAYLOAD_BYTES); + const { stdin, lastFrame } = render(); + await tick(); + stdin.write(COPY_KEY); + await tick(); + expect(lastFrame()).toContain("[warning] Sent thing"); + }); + + it("W saves the value to a file and shows its path", async () => { + const { stdin, lastFrame } = render( + , + ); + await tick(); + stdin.write(SAVE_KEY); + await tick(); + const frame = (lastFrame() ?? "").replace(/\n/g, ""); + const match = /Saved thing to (\S+value\.txt)/.exec(frame); + expect(match).not.toBeNull(); + savedPaths.push(match![1]!); + expect(fs.readFileSync(match![1]!, "utf8")).toBe("saved-value"); + }); + + it("reports a save failure (Error and non-Error)", async () => { + const spy = vi.spyOn(fs, "mkdtempSync").mockImplementationOnce(() => { + throw new Error("disk full"); + }); + const { stdin, lastFrame } = render(); + await tick(); + stdin.write(SAVE_KEY); + await tick(); + expect(lastFrame()).toContain("[error] Could not save thing: disk full"); + + spy.mockImplementationOnce(() => { + throw "plain string"; + }); + stdin.write(SAVE_KEY); + await tick(); + expect(lastFrame()).toContain("Could not save thing: plain string"); + }); + + it("consumes nothing when there is no value, and ignores other keys", async () => { + const consumed: boolean[] = []; + const { stdin, lastFrame } = render( + , + ); + await tick(); + stdin.write(COPY_KEY); + await tick(); + const withValue: boolean[] = []; + const second = render(); + await tick(); + second.stdin.write("q"); + await tick(); + expect(consumed).toEqual([false]); + expect(withValue).toEqual([false]); + expect(lastFrame()).toContain("no status"); + }); + + it("clears the status when the value changes", async () => { + const { stdin, lastFrame, rerender } = render( + , + ); + await tick(); + stdin.write(COPY_KEY); + await tick(); + expect(lastFrame()).toContain("Copied thing"); + rerender(); + await tick(); + expect(lastFrame()).toContain("no status"); + }); +}); + +describe("copyStatusColor", () => { + it("maps each tone to an Ink colour", () => { + expect(copyStatusColor("success")).toBe("green"); + expect(copyStatusColor("warning")).toBe("yellow"); + expect(copyStatusColor("error")).toBe("red"); + }); +}); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 8a55de0ea2..f6b3de0f84 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -91,6 +91,7 @@ import { ToolTestModal } from "./components/ToolTestModal.js"; import { ResourceTestModal } from "./components/ResourceTestModal.js"; import { PromptTestModal } from "./components/PromptTestModal.js"; import { DetailsModal } from "./components/DetailsModal.js"; +import { toCopyText } from "./utils/clipboard.js"; import { BodyLines } from "./components/BodyLines.js"; import type { TuiServer } from "./tui-servers.js"; import { errorMessage, redactErrorText } from "./utils/errorText.js"; @@ -233,6 +234,8 @@ function App({ const [detailsModal, setDetailsModal] = useState<{ title: string; content: React.ReactNode; + /** Raw value behind `content`, for the modal's copy keys (#2421). */ + copyText: string; } | null>(null); // InspectorClient instances for each server @@ -2003,6 +2006,7 @@ function App({ setDetailsModal({ title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, content: renderResourceDetails(resource), + copyText: toCopyText(resource), }) } onFetchResource={() => { @@ -2076,6 +2080,7 @@ function App({ setDetailsModal({ title: `Prompt: ${prompt.name || "Unknown"}`, content: renderPromptDetails(prompt), + copyText: toCopyText(prompt), }) } onFetchPrompt={(prompt) => { @@ -2123,6 +2128,7 @@ function App({ setDetailsModal({ title: `Tool: ${tool.name || "Unknown"}`, content: renderToolDetails(tool), + copyText: toCopyText(tool), }) } modalOpen={!!(toolTestModal || detailsModal)} @@ -2158,6 +2164,7 @@ function App({ setDetailsModal({ title: `Message: ${label}`, content: renderMessageDetails(message), + copyText: toCopyText(message), }); }} /> @@ -2185,6 +2192,7 @@ function App({ setDetailsModal({ title: `Request: ${request.method} ${request.url}`, content: renderRequestDetails(request), + copyText: toCopyText(request), }); }} /> @@ -2257,6 +2265,7 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setDetailsModal(null)} + copyText={detailsModal.copyText} /> )} diff --git a/clients/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx index e3844d0db7..2b96fd03a5 100644 --- a/clients/tui/src/components/AuthTab.tsx +++ b/clients/tui/src/components/AuthTab.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { SelectableItem } from "./SelectableItem.js"; +import { copyStatusColor, useCopyKeys } from "../hooks/useCopyKeys.js"; import type { MCPServerConfig, InspectorClient, @@ -175,6 +176,14 @@ export function AuthTab({ }; }, [inspectorClient, refreshOAuthState]); + const accessToken = oauthState?.tokens?.access_token; + // Y / W copy or save the full access token — the row below shows only a + // prefix, and terminal mouse-selection cannot reach the rest (#2421). + const { handleCopyKey, status: copyStatus } = useCopyKeys( + accessToken, + "access token", + ); + useInput( (input: string, key: Key) => { if (!focused) return; @@ -207,6 +216,8 @@ export function AuthTab({ return; } + if (handleCopyKey(input)) return; + if (key.upArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(-1); } else if (key.downArrow && scrollViewRef.current) { @@ -275,7 +286,6 @@ export function AuthTab({ } const scopes = oauthState ? formatScopes(oauthState) : undefined; - const accessToken = oauthState?.tokens?.access_token; return ( @@ -408,6 +418,11 @@ export function AuthTab({ value={`${accessToken.slice(0, 24)}…`} /> )} + {accessToken && copyStatus && ( + + {copyStatus.message} + + )} ) : ( @@ -456,7 +471,7 @@ export function AuthTab({ {pendingStepUp ? "↑/↓ select, Enter confirm, A authorize, C cancel" - : `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll`} + : `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ${accessToken ? "Y copy token, W save token, " : ""}↑/↓ scroll`} )} diff --git a/clients/tui/src/components/DetailsModal.tsx b/clients/tui/src/components/DetailsModal.tsx index e01b555d3c..0b8da4a899 100644 --- a/clients/tui/src/components/DetailsModal.tsx +++ b/clients/tui/src/components/DetailsModal.tsx @@ -1,6 +1,7 @@ import React, { useRef } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { copyStatusColor, useCopyKeys } from "../hooks/useCopyKeys.js"; interface DetailsModalProps { title: string; @@ -8,6 +9,11 @@ interface DetailsModalProps { width: number; height: number; onClose: () => void; + /** + * The raw text behind `content`, offered to Y (copy via OSC 52) and W (save + * to a file) — #2421. Omit it and the modal offers no copy affordance. + */ + copyText?: string; } export function DetailsModal({ @@ -16,8 +22,13 @@ export function DetailsModal({ width, height, onClose, + copyText, }: DetailsModalProps) { const scrollViewRef = useRef(null); + const { handleCopyKey, status: copyStatus } = useCopyKeys( + copyText, + "details", + ); // Use full terminal dimensions const [terminalDimensions, setTerminalDimensions] = React.useState({ @@ -44,6 +55,8 @@ export function DetailsModal({ (input: string, key: Key) => { if (key.escape) { onClose(); + } else if (handleCopyKey(input)) { + return; } else if (key.downArrow) { scrollViewRef.current?.scrollBy(1); } else if (key.upArrow) { @@ -89,9 +102,21 @@ export function DetailsModal({ {title} - (Press ESC to close) + + {copyText === undefined + ? "(Press ESC to close)" + : "(Press ESC to close, Y to copy, W to save to a file)"} + + {copyStatus && ( + + + {copyStatus.message} + + + )} + {/* Content Area */} {content} diff --git a/clients/tui/src/hooks/useCopyKeys.ts b/clients/tui/src/hooks/useCopyKeys.ts new file mode 100644 index 0000000000..6e5631939d --- /dev/null +++ b/clients/tui/src/hooks/useCopyKeys.ts @@ -0,0 +1,80 @@ +import { useCallback, useState } from "react"; +import { useStdout } from "ink"; +import { copyViaOsc52, writeCopyFile } from "../utils/clipboard.js"; + +/** Copy to the terminal clipboard (OSC 52). Vim's "yank". */ +export const COPY_KEY = "y"; +/** Save to a temp file — the fallback for a terminal without OSC 52. */ +export const SAVE_KEY = "w"; + +export interface CopyStatus { + tone: "success" | "warning" | "error"; + message: string; +} + +/** + * The copy affordance shared by every TUI surface that offers one (#2421): + * `Y` sends the value to the terminal clipboard over OSC 52, and `W` saves it + * to a private temp file for terminals that ignore OSC 52. + * + * The caller owns its `useInput` handler (focus and modal gating differ per + * surface) and forwards keys to `handleCopyKey`, which reports whether it + * consumed the key. `value` is read at keypress time, so it may be undefined + * when there is nothing to copy — the keys are then not consumed. + */ +export function useCopyKeys(value: string | undefined, label: string) { + const { stdout } = useStdout(); + const [status, setStatus] = useState(null); + // A status describes the value it was produced for; once the value changes + // (another server's token, another entry) it would describe the wrong one. + // Cleared during render — React's "adjusting state on a prop change" + // pattern — so the stale line is never painted. + const [statusFor, setStatusFor] = useState(value); + if (statusFor !== value) { + setStatusFor(value); + setStatus(null); + } + + const handleCopyKey = useCallback( + (input: string): boolean => { + if (value === undefined) return false; + const key = input.toLowerCase(); + if (key === COPY_KEY) { + const result = copyViaOsc52(value, stdout); + setStatus( + result.large + ? { + tone: "warning", + message: `Sent ${label} (${result.chars} chars) to the clipboard via OSC 52. Large payloads may be truncated or dropped by the terminal — press W to save it to a file instead.`, + } + : { + tone: "success", + message: `Copied ${label} (${result.chars} chars) via OSC 52. Nothing pasted? Your terminal may not support OSC 52 — press W to save it to a file.`, + }, + ); + return true; + } + if (key === SAVE_KEY) { + try { + const file = writeCopyFile(value); + setStatus({ tone: "success", message: `Saved ${label} to ${file}` }); + } catch (err) { + setStatus({ + tone: "error", + message: `Could not save ${label}: ${err instanceof Error ? err.message : String(err)}`, + }); + } + return true; + } + return false; + }, + [value, label, stdout], + ); + + return { handleCopyKey, status }; +} + +/** Ink colour for a {@link CopyStatus} tone. */ +export function copyStatusColor(tone: CopyStatus["tone"]): string { + return tone === "success" ? "green" : tone === "warning" ? "yellow" : "red"; +} diff --git a/clients/tui/src/utils/clipboard.ts b/clients/tui/src/utils/clipboard.ts new file mode 100644 index 0000000000..3c93335d06 --- /dev/null +++ b/clients/tui/src/utils/clipboard.ts @@ -0,0 +1,99 @@ +// Getting a value out of the TUI and onto the user's clipboard (#2421). +// +// The web client copies through the browser Clipboard API (`CopyButton`). A +// terminal app has no such API, and the obvious substitutes — shelling out to +// `pbcopy` / `xclip` / `clip.exe` — write to the clipboard of the machine the +// *process* runs on, which over SSH is the remote host rather than the machine +// the user is sitting at. OSC 52 is the escape sequence that asks the +// *terminal emulator* to set its clipboard, so it reaches the local clipboard +// through any number of SSH hops; that is why it is the mechanism here and why +// no clipboard package is needed. +// +// It is fire-and-forget: the terminal sends no acknowledgement, so a terminal +// that ignores OSC 52 (or caps its payload size) fails silently. The fallback +// for that case is `writeCopyFile`, which saves the raw value to a private temp +// file whose path the TUI then shows — readable with `cat` or `scp` from +// anywhere, and not bounded by the terminal's line width or scrollback. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Encoded payload size above which a copy is flagged as "large". Several + * terminals cap or drop OSC 52 payloads around this size (hterm's default is + * 100,000 bytes; others are lower), so the status line suggests the file + * fallback rather than letting a truncated paste go unexplained. + */ +export const OSC52_LARGE_PAYLOAD_BYTES = 100_000; + +/** The text to copy for a value: strings verbatim, anything else as JSON. */ +export function toCopyText(value: unknown): string { + if (typeof value === "string") return value; + try { + // JSON.stringify returns undefined for undefined / functions / symbols. + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + // A cycle or a BigInt: still give the user something rather than nothing. + return String(value); + } +} + +/** + * The OSC 52 "set clipboard" sequence for `text`, terminated with BEL (the + * terminator the widest set of terminals accepts). `c` selects the clipboard + * rather than the primary selection. + */ +export function buildOsc52Sequence(text: string): string { + const encoded = Buffer.from(text, "utf8").toString("base64"); + return `\u001b]52;c;${encoded}\u0007`; +} + +export interface Osc52CopyResult { + /** Characters in the copied text. */ + chars: number; + /** Bytes of base64 payload sent to the terminal. */ + payloadBytes: number; + /** True when the payload exceeds {@link OSC52_LARGE_PAYLOAD_BYTES}. */ + large: boolean; +} + +/** The slice of a writable stream `copyViaOsc52` needs. */ +export interface EscapeSink { + write(chunk: string): unknown; +} + +/** + * Emit `text` to the terminal as an OSC 52 clipboard write. + * + * Written straight to the stream rather than through Ink's `write`, which + * would erase and redraw the frame around it: the sequence has no visible + * output and moves no cursor, so it can go out between frames untouched. + */ +export function copyViaOsc52(text: string, sink: EscapeSink): Osc52CopyResult { + const sequence = buildOsc52Sequence(text); + sink.write(sequence); + // Everything but the payload is fixed framing (ESC ] 5 2 ; c ; … BEL), and + // base64 is ASCII, so string length is byte length. + const payloadBytes = sequence.length - buildOsc52Sequence("").length; + return { + chars: text.length, + payloadBytes, + large: payloadBytes > OSC52_LARGE_PAYLOAD_BYTES, + }; +} + +/** + * Save `text` to a fresh, owner-only temp file and return its path — the + * fallback for a terminal that ignores OSC 52. The directory is created with + * `mkdtemp` (mode 0700) and the file with mode 0600, because the value may be + * a bearer token. + */ +export function writeCopyFile( + text: string, + baseDir: string = os.tmpdir(), +): string { + const dir = fs.mkdtempSync(path.join(baseDir, "mcp-inspector-copy-")); + const file = path.join(dir, "value.txt"); + fs.writeFileSync(file, text, { encoding: "utf8", mode: 0o600 }); + return file; +} From a3119d9c91dfc063979e11044e91f9ec9d8781dd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 05:05:42 -0400 Subject: [PATCH 2/2] fix(tui): tie Auth tab OAuth state to its client so Y/W cannot copy a stale token AuthTab is reused across server switches, so the previous server's OAuth state stayed on screen until the new server's read landed, and a late read from the old client could overwrite the new one's. Now that Y/W can copy or save the access token, that window would hand over the wrong server's bearer token. The state is now stored with the client it was read from and shown only while that client is selected; stale completions are dropped. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/AuthTab.test.tsx | 71 ++++++++++++++++++++++++++ clients/tui/src/components/AuthTab.tsx | 25 +++++++-- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index 67937f60d6..0f0b144d73 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -663,4 +663,75 @@ describe("AuthTab", () => { await tick(); expect(stdout.frames.some((f) => f.includes("\u001b]52;"))).toBe(false); }); + + describe("server switch (#2421)", () => { + const withToken = (token: string): OAuthConnectionState => ({ + ...sampleOAuthState, + tokens: { access_token: token, token_type: "Bearer" }, + }); + /** A client whose getOAuthState resolves only when the test says so. */ + function deferredClient() { + let resolve: (s: OAuthConnectionState) => void = () => {}; + const pending = new Promise((r) => { + resolve = r; + }); + const client = { + getOAuthState: vi.fn(() => pending), + addEventListener: () => {}, + removeEventListener: () => {}, + // Double cast: a partial test double covering only the three members + // AuthTab calls, same as makeClient above; InspectorClient is a class + // with private state, so no structural single cast can reach it. + } as unknown as InspectorClient; + return { client, resolve }; + } + const tab = (client: InspectorClient, serverName: string) => ( + + ); + + it("does not show or copy the previous server's token while the next one loads", async () => { + const a = makeClient(withToken("token-for-server-A-xxxxxxxx")); + const b = deferredClient(); + const { stdin, stdout, lastFrame, rerender } = render(tab(a.client, "A")); + await tick(); + expect(lastFrame()).toContain("token-for-server-A"); + + rerender(tab(b.client, "B")); + await tick(); + expect(lastFrame()).not.toContain("token-for-server-A"); + stdin.write("y"); + await tick(); + expect( + stdout.frames.some((f) => + f.includes(buildOsc52Sequence("token-for-server-A-xxxxxxxx")), + ), + ).toBe(false); + + b.resolve(withToken("token-for-server-B-yyyyyyyy")); + await tick(); + expect(lastFrame()).toContain("token-for-server-B"); + }); + + it("ignores a read that settles after the selection moved on", async () => { + const a = deferredClient(); + const b = makeClient(withToken("token-for-server-B-yyyyyyyy")); + const { lastFrame, rerender } = render(tab(a.client, "A")); + await tick(); + rerender(tab(b.client, "B")); + await tick(); + expect(lastFrame()).toContain("token-for-server-B"); + + a.resolve(withToken("token-for-server-A-xxxxxxxx")); + await tick(); + expect(lastFrame()).toContain("token-for-server-B"); + expect(lastFrame()).not.toContain("token-for-server-A"); + }); + }); }); diff --git a/clients/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx index 2b96fd03a5..62f378f4ef 100644 --- a/clients/tui/src/components/AuthTab.tsx +++ b/clients/tui/src/components/AuthTab.tsx @@ -89,9 +89,21 @@ export function AuthTab({ const isLiveConnection = connectionStatus === "connected" || connectionStatus === "connecting"; const scrollViewRef = useRef(null); - const [oauthState, setOauthState] = useState< - OAuthConnectionState | undefined - >(undefined); + // The OAuth state is stored with the client it was read from, and shown only + // while that client is still the selected one. AuthTab is reused across + // server switches, so without this the previous server's state — bearer + // token included — stays on screen, and copyable with Y/W, until the new + // server's read lands (#2421). + const [oauthEntry, setOauthEntry] = useState<{ + client: InspectorClient | null; + state: OAuthConnectionState | undefined; + }>({ client: null, state: undefined }); + const oauthState = + oauthEntry.client === inspectorClient ? oauthEntry.state : undefined; + const inspectorClientRef = useRef(inspectorClient); + useEffect(() => { + inspectorClientRef.current = inspectorClient; + }, [inspectorClient]); const [clearState, setClearState] = useState< "idle" | "clearing" | "cleared" | "failed" >("idle"); @@ -138,11 +150,14 @@ export function AuthTab({ const refreshOAuthState = useCallback(async () => { if (!inspectorClient) { - setOauthState(undefined); + setOauthEntry({ client: null, state: undefined }); return; } const state = await inspectorClient.getOAuthState(); - setOauthState(state); + // A read that settles after the selection moved on belongs to a server + // no longer on screen; storing it would overwrite the current one's. + if (inspectorClientRef.current !== inspectorClient) return; + setOauthEntry({ client: inspectorClient, state }); }, [inspectorClient]); useEffect(() => {