diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5877f1bbda6c838828dc4df29ee38d9fa257eb6f..625efb562f977fa1b3d304d77cb75a58e06bf7a6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1665,21 +1665,20 @@ function SavedBackendListRow({ ) { setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); } + const prepared = usePreparedConnection(environmentId); + const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; const mcpUrl = environmentMcpUrl({ entry: environment.entry, relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, + connectedTarget, }); const machineKind = resolveEnvironmentMachineKind( environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), ); - const prepared = usePreparedConnection(environmentId); const routeCount = connectionRoutes(environment.entry).length; const subtitleText = [ - environmentTransportLabel( - environment, - isConnected && prepared._tag === "Some" ? prepared.value.target : null, - ), + environmentTransportLabel(environment, connectedTarget), resumingServerUpdate ? "Restarting" : status.text, enabled && versionMismatch ? serverVersion : null, ] diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 70129139206fdad31049bda991c14f9a5b3ee4ea..86294d68dc3c87acd300c78900e5c0586e2f8653 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -16,11 +16,12 @@ example: https:///mcp ``` -The address must use HTTPS, or `localhost` when the agent runs on the host -itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address. +The copied URL uses the route this device is connected over, so an agent on the +same device can reach it. -- **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or - `localhost` on the host. See [remote access](./remote-access.md). +- **An agent on your own computers** can use any address that computer reaches + the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on + the host. See [remote access](./remote-access.md). - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach the environment from the internet. Use the T3 Connect address. A Tailscale address only works from your own tailnet. diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e9f4fe0d57eb6b7c94286523863a19b4679cfed9..61d6278a5305158a80cc8ff97aadbe69ff991f38 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + type ConnectionRoute, +} from "./catalog.ts"; import { BearerConnectionTarget, ConnectionBlockedError, ConnectionTransientError, + RelayConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; import { @@ -72,25 +77,62 @@ 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 => ({ + it("copies the MCP address of the route this device is connected over", () => { + const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { + const target = new BearerConnectionTarget({ ...TARGET, connectionId }); + return { + target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + }; + }; + const lan = route("lan", "http://192.168.4.53:3773/"); + const tailnet = route("tailnet", "http://100.115.1.44:3773/"); + const serve = route("serve", "https://machine.tailnet.ts.net/"); + const entry: ConnectionCatalogEntry = { ...ENTRY, - profile: Option.some( - new BearerConnectionProfile({ - connectionId: TARGET.connectionId, - environmentId: TARGET.environmentId, - label: TARGET.label, - httpBaseUrl, - wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), - }), - ), + target: lan.target, + profile: lan.profile, + alternateRoutes: [tailnet, serve], + }; + + expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( + "http://100.115.1.44:3773/mcp", + ); + // Not connected: the preferred route, plain http or not. + expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); + }); + + it("passes over routes without an address of their own", () => { + const relay = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, }); - expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( - "http://127.0.0.1:3773/mcp", + const entry: ConnectionCatalogEntry = { + ...ENTRY, + target: relay, + profile: Option.none(), + alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }], + }; + + // Relay discovery has not reported the tunnel address yet. + expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe( + "https://environment.example.test/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(); + expect( + environmentMcpUrl({ + entry, + connectedTarget: relay, + relayHttpBaseUrl: "https://tunnel.example.test", + }), + ).toBe("https://tunnel.example.test/mcp"); }); it("distinguishes initial connection, reconnect, and retry errors", () => { diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index a409847f0b5e49490954ec6b103ad993ad6fcefe..9015a755133e5bc4d9740596d5ce81e76e165556 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { SupervisorConnectionState } from "./model.ts"; +import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; +import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; export type EnvironmentConnectionPhase = | "available" @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( /** * 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. + * environment's MCP server: the route this device is connected over, since an + * agent beside this client can reach it too, else the first route in + * preference order that has an address. SSH connections ride a local forward + * that disappears with the client, so they have no stable address. */ export function environmentMcpUrl(input: { readonly entry: ConnectionCatalogEntry; readonly relayHttpBaseUrl?: string | undefined; + readonly connectedTarget?: ConnectionTarget | null | 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; + const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; + const routes = connectionRoutes(input.entry); + const connectedRoute = routes.find( + (route) => connectionRouteId(route.target) === connectedRouteId, + ); + for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { + const httpBaseUrl = + route.target._tag === "RelayConnectionTarget" + ? (input.relayHttpBaseUrl ?? null) + : routeHttpBaseUrl(route); + const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl); + if (mcpUrl !== null) return mcpUrl; + } + return null; +} + +function mcpUrlFromBase(httpBaseUrl: string): string | 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 = "";