diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 42084ab8d5a4..f915ca4457df 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -48,6 +48,7 @@ import { RelayConnectionTarget, connectionRoutes, connectionStatusText, + environmentMcpUrl, } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, @@ -1526,6 +1527,25 @@ function SavedBackendListRow({ }, [copyTraceIdToClipboard], ); + const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({ + target: "MCP URL", + onCopy: ({ url }) => { + toastManager.add({ + type: "success", + title: "MCP URL copied", + description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy MCP URL", + description: error.message, + }), + ); + }, + }); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = @@ -1545,6 +1565,20 @@ function SavedBackendListRow({ if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) { setLastDescriptor(discoveredDescriptor); } + // Held for the same reason as the descriptor, so Copy MCP URL survives a refresh. + const discoveredRelayHttpBaseUrl = + relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl; + const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl); + if ( + discoveredRelayHttpBaseUrl !== undefined && + discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl + ) { + setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); + } + const mcpUrl = environmentMcpUrl({ + entry: environment.entry, + relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, + }); const machineKind = resolveEnvironmentMachineKind( environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), @@ -1711,6 +1745,9 @@ function SavedBackendListRow({ {routesOpen ? "Hide routes" : "Routes"} + {mcpUrl ? ( + copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL + ) : null} {errorTraceId ? ( copyTraceId(errorTraceId)}>Copy trace ID ) : null} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index a5f8bbae3af2..7460a90cd7ea 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -172,6 +172,34 @@ running is left alone. For Antigravity's Google callback on a remote host, see [remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device). +## Connect an outside agent + +An agent T3 Code did not start, such as Claude Code in your own terminal, can +drive threads on an environment through its MCP server. In **Settings → +Connections**, open a saved environment's menu and choose **Copy MCP URL**, then +add it to the agent. For example: + +```sh +claude mcp add --transport http t3 https:///mcp +``` + +The first time the agent connects, it opens a sign-in page on the environment. +Enter a pairing code from **Settings → Connections** on a device that can manage +access, or from `t3 auth pairing create` on the host, and choose what the agent +may do. A browser already signed in to that environment as an administrator can +approve without a code. + +- **Read only** lets the agent read projects and threads in every project, and + see which providers and models are available. It cannot change anything. +- **Supervised** through **Full access** also let it start, message and stop + threads in every project, but it cannot start or steer a thread with more + permissions than the mode you chose. + +Use an HTTPS address: T3 Connect, Tailscale Serve, or `localhost` on the host +itself. Agents refuse to sign in through a plain `http://` LAN or tailnet +address. The agent appears under **Settings → Connections** like any other +client; revoke it there. Sign-ins last 30 days. + ## Manage or revoke access On the host, **Settings → Connections** lets authorized administrators create diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index fabc47034599..e9f4fe0d57eb 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -11,6 +11,7 @@ import { } from "./model.ts"; import { connectionCatalogDisplayUrl, + environmentMcpUrl, connectionStatusText, connectionStatusTitle, presentEnvironmentConnection, @@ -71,6 +72,27 @@ describe("connection presentation", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); + it("offers an MCP address only where an MCP client can sign in", () => { + expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); + const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ + ...ENTRY, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: TARGET.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + }); + expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( + "http://127.0.0.1:3773/mcp", + ); + // A plain-http LAN or tailnet address is refused by MCP clients' token checks. + expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); + }); + it("distinguishes initial connection, reconnect, and retry errors", () => { expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ phase: "connecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index f7586c5e3dbf..a409847f0b5e 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -92,6 +92,39 @@ export function presentEnvironmentConnection( return presentConnectionState(state); } +/** + * The address an agent outside T3 (Claude Code, Codex) uses to reach this + * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP + * clients refuse to sign in through a plain-http token endpoint elsewhere. + * SSH connections ride a local forward that disappears with the client, so + * they have no stable address to hand out. + */ +export function environmentMcpUrl(input: { + readonly entry: ConnectionCatalogEntry; + readonly relayHttpBaseUrl?: string | undefined; +}): string | null { + const httpBaseUrl = + input.entry.target._tag === "RelayConnectionTarget" + ? (input.relayHttpBaseUrl ?? null) + : input.entry.target._tag === "SshConnectionTarget" + ? null + : connectionCatalogDisplayUrl(input.entry); + if (httpBaseUrl === null) return null; + let url: URL; + try { + url = new URL(httpBaseUrl); + } catch { + return null; + } + const loopback = + url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null; + url.pathname = "/mcp"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { switch (entry.target._tag) { case "PrimaryConnectionTarget":