8000 feat(ui): copy the MCP address from the account menu after setup by adeelahmad · Pull Request #340 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4fc0c3a
ci: count clicks and keystrokes per feature, with a baseline
claude Oct 10, 2026
e7409ba
ci(clicks): read --compare from the caller's folder; scroll past a co…
claude Oct 10, 2026
8eee90d
ci(clicks): summary step without A && B || C
claude Oct 10, 2026
87d9366
Merge remote-tracking branch 'origin/claude/project-thread-th17qg' in…
claude Oct 10, 2026
7c8dbe5
ci(clicks): survey every module, and on a phone with taps and swipes
claude Oct 10, 2026
37473aa
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 10, 2026
298e1ca
ci(clicks): steady phone swipes and journey waits
claude Oct 10, 2026
38dd0db
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 10, 2026
dfdf90d
ci(clicks): tap New conversation where Chat opens on the list
claude Oct 10, 2026
e80ba1d
feat(ui): copy the MCP address from the account menu after setup
claude Oct 10, 2026
c598a1e
ci(clicks): phone baseline on main with the setup and Notes cuts
claude Oct 10, 2026
32c0cd0
ci(clicks): wait for the imports' analysis before finding an entity
claude Oct 10, 2026
88b6965
ci(clicks): wait on the Entities page, not the API
claude Oct 10, 2026
2e009b9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 10, 2026
4fbee6c
ci(clicks): baselines on main with one-click install and named pipelines
claude Oct 10, 2026
2094a5c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 10, 2026
65e8c94
Merge remote-tracking branch 'origin/claude/project-thread-kcokci' in…
claude Oct 11, 2026
1a83d25
ci(clicks): make the podcast journey pick the pasted transcript
claude Oct 11, 2026
1a53a87
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 11, 2026
0b1524d
ci(clicks): agent-connect baselines from a run on the merged survey b…
claude Oct 11, 2026
6a8c5a9
Merge remote-tracking branch 'origin/claude/project-thread-kcokci' in…
claude Oct 11, 2026
bcd7836
ci(clicks): baselines on main with the graph opening on your data
claude Oct 11, 2026
53ec7ed
Merge remote-tracking branch 'origin/claude/project-thread-kcokci' in…
claude Oct 11, 2026
486662c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 11, 2026
47df37e
ci(clicks): port the cloud-model journey through the presets (#350), …
claude Oct 11, 2026
64f6448
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 11, 2026
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
28 changes: 22 additions & 6 deletions ci/clicks/baseline-phone.json
Original file line number Diff line number Diff line change
Expand Up @@ -894,18 +894,34 @@
{
"module": "Agents",
"feature": "Connect an AI assistant (Claude, ChatGPT, Cursor) over MCP",
"note": "In the assistant: add a connector and give it Lens's MCP address; the assistant opens Lens's consent page, where the person allows it. After setup Lens shows the MCP address nowhere, so it is typed (counted as typed).",
"clicks": 3,
"keys": 25,
"note": "Copy MCP address in the account menu; in the assistant: add a connector and paste the address; the assistant opens Lens's consent page, where the person allows it.",
"clicks": 5,
"keys": 1,
"prompts": 0,
"swipes": 0,
"steps": [
{
"page": "/",
"kind": "tap",
"what": "Account menu",
"clicks": 1,
"keys": 0,
"prompts": 0
},
{
"page": "/",
"kind": "tap",
"what": "Copy MCP address",
"clicks": 1,
"keys": 0,
"prompts": 0
},
{
"page": "/",
"kind": "outside",
"what": "in the assistant: add a connector, type Lens's MCP address, add it",
"what": "in the assistant: add a connector, paste Lens's MCP address, add it",
"clicks": 2,
"keys": 25,
"keys": 1,
"prompts": 0
},
{
Expand All @@ -918,7 +934,7 @@
}
],
"id": "agent-connect",
"ms": 2710,
"ms": 3259,
"ok": true
},
{
Expand Down
28 changes: 22 additions & 6 deletions ci/clicks/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -793,18 +793,34 @@
{
"module": "Agents",
"feature": "Connect an AI assistant (Claude, ChatGPT, Cursor) over MCP",
"note": "In the assistant: add a connector and give it Lens's MCP address; the assistant opens Lens's consent page, where the person allows it. After setup Lens shows the MCP address nowhere, so it is typed (counted as typed).",
"clicks": 3,
"keys": 25,
"note": "Copy MCP address in the account menu; in the assistant: add a connector and paste the address; the assistant opens Lens's consent page, where the person allows it.",
"clicks": 5,
"keys": 1,
"prompts": 0,
"swipes": 0,
"steps": [
{
"page": "/",
"kind": "click",
"what": "Account menu",
"clicks": 1,
"keys": 0,
"prompts": 0
},
{
"page": "/",
"kind": "click",
"what": "Copy MCP address",
"clicks": 1,
"keys": 0,
"prompts": 0
},
{
"page": "/",
"kind": "outside",
"what": "in the assistant: add a connector, type Lens's MCP address, add it",
"what": "in the assistant: add a connector, paste Lens's MCP address, add it",
"clicks": 2,
"keys": 25,
"keys": 1,
"prompts": 0
},
{
Expand All @@ -817,7 +833,7 @@
}
],
"id": "agent-connect",
"ms": 2528,
"ms": 2753,
"ok": true
},
{
Expand Down
10 changes: 8 additions & 2 deletions ci/clicks/journeys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -409,12 +409,18 @@ export const JOURNEYS = [
ns: "",
module: "Agents",
feature: "Connect an AI assistant (Claude, ChatGPT, Cursor) over MCP",
note: "In the assistant: add a connector and give it Lens's MCP address; the assistant opens Lens's consent page, where the person allows it. After setup Lens shows the MCP address nowhere, so it is typed (counted as typed).",
note: "Copy MCP address in the account menu; in the assistant: add a connector and paste the address; the assistant opens Lens's consent page, where the person allows it.",
from: "/",
async run(ctx) {
const { page, meter, base } = ctx;
const mcp = `${base}/mcp`;
meter.record("outside", "in the assistant: add a connector, type Lens's MCP address, add it", 2, mcp.length);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: base });
await meter.click(page.getByRole("button", { name: "Account menu" }), "Account menu");
await meter.click(page.getByRole("menuitem", { name: "Copy MCP address" }), "Copy MCP address");
await page.getByText("MCP address copied").waitFor();
const copied = await page.evaluate(() => navigator.clipboard.readText());
if (copied !== mcp) throw new Error(`copied ${copied}, not ${mcp}`);
meter.record("outside", "in the assistant: add a connector, paste Lens's MCP address, add it", 2, 1);
// What the assistant does by itself (RFC 7591 registration, PKCE), then it opens the consent page
const redirect = "http://127.0.0.1:9/callback";
const r = await fetch(`${base}/api/v1/oauth/register`, {
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ It works the other way too: Lens's own assistant calls the tools of MCP servers

The server is at `/mcp` on the web app's address: `https://lens.example.org/mcp` (`http://localhost:3000/mcp`
locally). Add it to the client as a remote (Streamable HTTP) server, and the client sends you to Lens to sign in.
In the web app, **Copy MCP address** in the account menu (your picture, top right) copies it in one click; it is also
under API tokens, above Apps with access, and in the setup wizard's Apps step.

| Client | |
|---|---|
Expand Down
83 changes: 83 additions & 0 deletions nextjs-frontend/__tests__/connect-assistant.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";

import { ConnectedApps } from "@/components/account/apps";
import { connectHint } from "@/components/account/connect-assistant";
import { AccountMenu } from "@/components/app-shell/account-menu";
import { TooltipProvider } from "@/components/ui/tooltip";

const ok = (data: unknown) => Promise.resolve({ data, response: { ok: true, status: 200 } });
jest.mock("@/app/openapi-client", () => ({
Oauth: { listGrants: () => ok([]), revokeGrant: () => ok({}) },
Tokens: { listTokens: () => ok([]) },
}));
jest.mock("next-auth/react", () => ({ useSession: () => ({ data: { accessToken: "t" } }), signOut: jest.fn() }));
jest.mock("@/lib/hooks/session", () => ({
useArchive: () => ({ me: { user: { name: "Vi", email: "vi@x.io" }, roles: {} }, admin: false }),
}));
const toast = jest.fn();
jest.mock("@/components/ui/toast", () => ({ useToast: () => toast }));

const MCP = `${window.location.origin}/mcp`;
let writeText: jest.Mock;
let signIn: boolean;

beforeEach(() => {
signIn = true;
writeText = jest.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
// OAuth discovery answers only while apps may sign in
global.fetch = jest.fn(() => Promise.resolve({ ok: signIn } as Response));
});

function show(ui: React.ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>{ui}</TooltipProvider>
</QueryClientProvider>,
);
}

describe("connecting an assistant", () => {
it("shows the MCP address with Copy above the apps with access", async () => {
show(<ConnectedApps />);
expect(await screen.findByText(MCP)).toBeInTheDocument();
expect(await screen.findByText(/Add it to Claude, ChatGPT, Cursor/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Copy MCP address" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith(MCP));
expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument();
});

it("says to give an API token while apps can't sign in", async () => {
signIn = false;
show(<ConnectedApps />);
expect(await screen.findByText(/give the assistant an API token/)).toBeInTheDocument();
expect(connectHint(undefined)).toMatch(/as a connector/);
});

it("copies the address from the account menu in one click", async () => {
show(<AccountMenu name="Vi" email="vi@x.io" />);
fireEvent.keyDown(screen.getByRole("button", { name: "Account menu" }), { key: "Enter" });
fireEvent.click(await screen.findByRole("menuitem", { name: "Copy MCP address" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith(MCP));
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "MCP address copied", body: expect.stringContaining(MCP), tone: "green" }),
),
);
});

it("shows the address when the browser won't copy", async () => {
writeText.mockRejectedValue(new Error("denied"));
show(<AccountMenu name="Vi" email="vi@x.io" />);
fireEvent.keyDown(screen.getByRole("button", { name: "Account menu" }), { key: "Enter" });
fireEvent.click(await screen.findByRole("menuitem", { name: "Copy MCP address" }));
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Copy the MCP address", body: expect.stringContaining(MCP) }),
),
);
});
});
7 changes: 6 additions & 1 deletion nextjs-frontend/components/account/apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useState } from "react";

import { Oauth } from "@/app/openapi-client";
import type { OAuthGrant } from "@/app/openapi-client/types.gen";
import { ConnectAssistant } from "@/components/account/connect-assistant";
import { tokenExpiry } from "@/components/account/token-model";
import { ACCESS_LABEL, appHost } from "@/components/oauth/model";
import { Banner } from "@/components/ui/banner";
Expand All @@ -17,7 +18,10 @@ import { useToast } from "@/components/ui/toast";
import { data, useApiClient } from "@/lib/api/browser";
import { relative, shortDate } from "@/lib/format";

/** The apps this person gave access to through OAuth (docs/authentication.md#oauth), each with Revoke. */
/**
* The apps this person gave access to through OAuth (docs/authentication.md#oauth), each with Revoke, and above them
* the MCP address to connect another.
*/
export function ConnectedApps() {
const client = useApiClient();
const qc = useQueryClient();
Expand Down Expand Up @@ -53,6 +57,7 @@ export function ConnectedApps() {
until you revoke them.
</p>
</div>
<ConnectAssistant />
{grants.isPending ? (
<SkeletonRows rows={2} />
) : grants.isError ? (
Expand Down
48 changes: 48 additions & 0 deletions nextjs-frontend/components/account/connect-assistant.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";

import { CodeBlock } from "@/components/ui/states";

/** Where AI assistants and other MCP clients connect: the web app's own address plus /mcp (docs/mcp.md). */
export function mcpAddress(): string {
return typeof window === "undefined" ? "/mcp" : `${window.location.origin}/mcp`;
}

/** The MCP address once the page is in the browser (the server can't know the address people use). */
export function useMcpAddress(): string {
const [mcp, setMcp] = useState("/mcp");
useEffect(() => setMcp(mcpAddress()), []);
return mcp;
}

/** Whether apps may sign people in here (tokens.oauth_enabled): OAuth discovery answers only when they may. */
export function useAppSignIn(enabled = true) {
return useQuery({
queryKey: ["oauth", "on"],
queryFn: async () => (await fetch("/.well-known/oauth-authorization-server", { cache: "no-store" })).ok,
enabled,
staleTime: 30_000,
});
}

/** What to do with the address: add it as a connector, or give a token while apps can't sign in. */
export function connectHint(signIn: boolean | undefined): string {
return signIn === false
? "Apps can’t sign in here, so give the assistant an API token as its key (an admin turns sign-in on in Settings → API keys)."
: "Add it to Claude, ChatGPT, Cursor or another MCP client as a connector; you allow it here once.";
}

/** The MCP address with Copy, so connecting an assistant after setup is a copy and a paste (API tokens page). */
export function ConnectAssistant() {
const mcp = useMcpAddress();
const signIn = useAppSignIn();
return (
<div id="connect" className="flex flex-col gap-1.5">
<span className="text-[13px] font-semibold text-fg">Connect an assistant</span>
<CodeBlock inline text={mcp} label="MCP address" className="self-start" />
<p className="text-[12.5px] leading-[1.45] text-fg-secondary">{connectHint(signIn.data)}</p>
</div>
);
}
27 changes: 25 additions & 2 deletions nextjs-frontend/components/app-shell/account-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { KeyRound, Keyboard, LogOut, Signpost, SunMoon, UserRound, Users } from "lucide-react";
import { KeyRound, Keyboard, LogOut, Plug, Signpost, SunMoon, UserRound, Users } from "lucide-react";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { useState } from "react";

import { Tokens } from "@/app/openapi-client";
import { connectHint, mcpAddress, useAppSignIn } from "@/components/account/connect-assistant";
import { useTheme, type ThemeChoice } from "@/components/app-shell/theme";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu";
import { Avatar, Skeleton } from "@/components/ui/states";
import { useToast } from "@/components/ui/toast";
import { data, useApiClient } from "@/lib/api/browser";
import { useArchive } from "@/lib/hooks/session";

Expand All @@ -31,12 +33,17 @@ export function rolesSummary(admin: boolean, roles: Record<string, string>, part
return list.slice(0, 3).join(" · ") + (list.length > 3 ? ` · +${list.length - 3} more` : "");
}

/** Name, roles, profile and password, API tokens, appearance, shortcuts, the welcome tour, sign out (Access AC4). */
/**
* Name, roles, profile and password, API tokens, the MCP address to connect an assistant, appearance, shortcuts, the
* welcome tour, sign out (Access AC4).
*/
export function AccountMenu({ name, email }: { name?: string | null; email: string }) {
const { me, admin } = useArchive();
const client = useApiClient();
const [open, setOpen] = useState(false);
const [theme, setTheme] = useTheme();
const toast = useToast();
const signIn = useAppSignIn(open);
const tokens = useQuery({
queryKey: ["tokens"],
queryFn: () => data(Tokens.listTokens({ client })),
Expand All @@ -46,6 +53,16 @@ export function AccountMenu({ name, email }: { name?: string | null; email: stri
const roles = (me?.roles ?? {}) as Record<string, string>;
const owns = !admin && Object.values(roles).includes("owner");
const display = me?.user.name || name || me?.user.email || email;
// In the click itself, not after the menu closes: some browsers only let a click write to the clipboard.
const copyMcp = async () => {
const mcp = mcpAddress();
try {
await navigator.clipboard.writeText(mcp);
toast({ title: "MCP address copied", body: `${mcp}. ${connectHint(signIn.data)}`, tone: "green" });
} catch {
toast({ title: "Copy the MCP address", body: `${mcp}. It’s also under API tokens.`, tone: "gate" });
}
};

return (
<Menu open={open} >
Expand Down Expand Up @@ -76,6 +93,12 @@ export function AccountMenu({ name, email }: { name?: string | null; email: stri
{tokens.data && <span className="tabular text-[12px] font-normal text-fg-muted">{tokens.data.length}</span>}
</Link>
</MenuItem>
<MenuItem asChild className={ITEM}>
<button type="button" className="w-full" => void copyMcp()}>
<Plug />
<span className="flex-1 text-left">Copy MCP address</span>
</button>
</MenuItem>
{owns && (
<MenuItem asChild className={ITEM}>
<Link href="/admin/namespaces">
Expand Down
AD62 3 changes: 2 additions & 1 deletion nextjs-frontend/components/setup/setup-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type SetupView,
type TelemetryTestResult,
} from "@/app/openapi-client";
import { mcpAddress } from "@/components/account/connect-assistant";
import { AuthAlert, AuthBrand } from "@/components/auth/auth-card";
import { SegmentedChoice } from "@/components/settings/controls";
import {
Expand Down Expand Up @@ -950,7 +951,7 @@ function AppsStep({ view, onNext }: { view: SetupView; onNext: (saved: boolean)
const [minutes, setMinutes] = useState(String(o.access_minutes));
const [days, setDays] = useState(String(o.refresh_days));
const [copied, setCopied] = useState(false);
const mcp = typeof window === "undefined" ? "/mcp" : `${window.location.origin}/mcp`;
const mcp = mcpAddress();
const mins = Number(minutes);
const ds = Number(days);
const validMinutes = Number.isInteger(mins) && mins >= 5 && mins <= 1440;
Expand Down
Loading
0