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
9 changes: 9 additions & 0 deletions clients/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
111 changes: 111 additions & 0 deletions clients/tui/__tests__/AuthTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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++)
Expand Down Expand Up @@ -623,4 +624,114 @@ 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(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
focused
/>,
);
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(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
focused
/>,
);
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);
});

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<OAuthConnectionState>((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) => (
<AuthTab
{...baseProps}
serverName={serverName}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
focused
/>
);

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");
});
});
});
51 changes: 50 additions & 1 deletion clients/tui/__tests__/DetailsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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(
<Box width={120} height={30}>
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
copyText='{"raw":true}'
/>
</Box>,
);

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(
<Box width={120} height={30}>
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={() => {}}
/>
</Box>,
);

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);
});
});
86 changes: 86 additions & 0 deletions clients/tui/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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);
});
});
Loading
Loading