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 ? (
+
+ ) : null}
{errorTraceId ? (
) : 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":