diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index b279e331f3db2..481cbfaf3fe73 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -196,7 +196,10 @@ describe("ConnectReachability stale connector recovery", () => { // Reaching the endpoint resets the wait, and a 530 for a broken DNS record is left alone. status = 200; - expect(yield* reachability.check).toMatchObject({ status: "reachable" }); + expect(yield* reachability.check).toMatchObject({ + status: "reachable", + hostname: connected.hostname, + }); status = 530; body = { cloudflareError: 1016 }; expect(yield* reachability.check).toMatchObject({ cause: "unexpected_response" }); diff --git a/apps/server/src/cloud/ConnectReachability.ts b/apps/server/src/cloud/ConnectReachability.ts index aa9697673295c..6b12e8336d69c 100644 --- a/apps/server/src/cloud/ConnectReachability.ts +++ b/apps/server/src/cloud/ConnectReachability.ts @@ -98,6 +98,7 @@ export function classifyReachability( checkedAt, readyConnections: connector.readyConnections, protocol: connector.protocol, + ...(connector.hostname ? { hostname: connector.hostname } : {}), }; } // The edge test is TCP only. With QUIC connections up, a 502 from a working tunnel, or diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 087f73589f329..e75499e0a5efc 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -135,6 +135,7 @@ import { ITEM_ROW_CLASSNAME } from "./itemRows"; import { CloudLinkRow, CloudRemoteEnvironmentRows, + ConnectMcpUrlMenuItem, SavedBackendListRow, } from "./SavedEnvironmentRows"; import { AddEnvironmentDialog } from "./AddEnvironmentDialog"; @@ -1174,6 +1175,7 @@ function ConnectionsSettingsContent({ environmentId={accessEnvironmentId} serverConfig={accessServerConfig} /> + ) : null diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 53b745412d983..ff690e1cde9a2 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -50,6 +50,7 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; +import { useCloudLinkState } from "~/cloud/cloudLinkState"; import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; import { useT3ConnectAccountPage } from "../clerk/T3ConnectAccountPages"; import { TunnelLimitAlert, TunnelUsageMeter } from "../cloud/TunnelUsage"; @@ -107,6 +108,44 @@ function savedBackendStatus(environment: EnvironmentPresentation): { } } +function useCopyMcpUrl() { + return 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, + }), + ); + }, + }).copyToClipboard; +} + +/** Copies the MCP URL this environment's own T3 Connect tunnel answered on. */ +export function ConnectMcpUrlMenuItem({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { + const { data } = useCloudLinkState(environmentId); + const copyMcpUrl = useCopyMcpUrl(); + // A stopped tunnel triggers no recheck, so the last result can outlive it. + const reachability = + data?.managedTunnelActive === true && data.linkRevoked !== true ? data.reachability : undefined; + if (reachability?.status !== "reachable" || !reachability.hostname) return null; + const mcpUrl = `https://${reachability.hostname}/mcp`; + return copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL; +} + export function SavedBackendListRow({ environment, removingEnvironmentId, @@ -146,25 +185,7 @@ export function SavedBackendListRow({ const copyTraceId = (traceId: string) => { copyTraceIdToClipboard(traceId, { traceId }); }; - 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 copyMcpUrl = useCopyMcpUrl(); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index e71dcfc7c00a1..5469221b1d26f 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -20,6 +20,10 @@ When T3 Connect serves the environment, the copied URL is its T3 Connect address, which agents can reach from anywhere. Otherwise it uses the route this device is connected over, so an agent on the same device can reach it. +For the environment at the top of **Settings → Sharing & access**, open the +menu next to its name. Once T3 Connect reports its tunnel connected, **Copy MCP +URL** there gives the address that tunnel serves. + - **An agent on your own computers** can use `localhost` on the host, T3 Connect, or any `https` address, such as [Tailscale HTTPS](./remote-access.md#tailscale-https). Signing in sends the diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 46dfcde197ac2..ab506f99b1dc6 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -450,6 +450,8 @@ export const EnvironmentConnectReachability = Schema.Union([ /** Null when cloudflared did not report a count. */ readyConnections: Schema.NullOr(Schema.Int), protocol: Schema.NullOr(Schema.String), + /** The host the tunnel answered on. Older servers leave it out. */ + hostname: Schema.optional(Schema.String), }), Schema.Struct({ status: Schema.Literal("unreachable"),