authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-10-07 17:51:31-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 14:51:31-07:00
log7ac5ec6b15593f5b682fe73b483bc8e24de76665
treeb65d67bc436f606c747c96b31553a61c6ef0226e
parent7f3628e4153d5be4a241c4d54f73a6d7087014ce
signature Signed by PGP key B5690EEEBB952194

fix(web): Copy MCP URL shows up for environments reached over plain http (#16909)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 92 insertions(+), 41 deletions(-)

apps/web/src/components/settings/ConnectionsSettings.tsx+4-5
...@@ -1665,21 +1665,20 @@ function SavedBackendListRow({...@@ -1665,21 +1665,20 @@ function SavedBackendListRow({
1665 ) {1665 ) {
1666 setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);1666 setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);
1667 }1667 }
1668 const prepared = usePreparedConnection(environmentId);
1669 const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null;
1668 const mcpUrl = environmentMcpUrl({1670 const mcpUrl = environmentMcpUrl({
1669 entry: environment.entry,1671 entry: environment.entry,
1670 relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,1672 relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
1673 connectedTarget,
1671 });1674 });
1672 const machineKind = resolveEnvironmentMachineKind(1675 const machineKind = resolveEnvironmentMachineKind(
1673 environment.serverConfig ??1676 environment.serverConfig ??
1674 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),1677 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
1675 );1678 );
1676 const prepared = usePreparedConnection(environmentId);
1677 const routeCount = connectionRoutes(environment.entry).length;1679 const routeCount = connectionRoutes(environment.entry).length;
1678 const subtitleText = [1680 const subtitleText = [
1679 environmentTransportLabel(1681 environmentTransportLabel(environment, connectedTarget),
1680 environment,
1681 isConnected && prepared._tag === "Some" ? prepared.value.target : null,
1682 ),
1683 resumingServerUpdate ? "Restarting" : status.text,1682 resumingServerUpdate ? "Restarting" : status.text,
1684 enabled && versionMismatch ? serverVersion : null,1683 enabled && versionMismatch ? serverVersion : null,
1685 ]1684 ]
docs/user/outside-agents.md+5-4
...@@ -16,11 +16,12 @@ example:...@@ -16,11 +16,12 @@ example:
16https://<environment-address>/mcp16https://<environment-address>/mcp
17```17```
1818
19The address must use HTTPS, or `localhost` when the agent runs on the host19The copied URL uses the route this device is connected over, so an agent on the
20itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address.20same device can reach it.
2121
22- **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or22- **An agent on your own computers** can use any address that computer reaches
23 `localhost` on the host. See [remote access](./remote-access.md).23 the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on
24 the host. See [remote access](./remote-access.md).
24- **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach25- **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach
25 the environment from the internet. Use the T3 Connect address. A Tailscale26 the environment from the internet. Use the T3 Connect address. A Tailscale
26 address only works from your own tailnet.27 address only works from your own tailnet.
packages/client-runtime/src/connection/presentation.test.ts+59-17
...@@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts";...@@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
44
5import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts";5import {
6 BearerConnectionProfile,
7 type ConnectionCatalogEntry,
8 type ConnectionRoute,
9} from "./catalog.ts";
6import {10import {
7 BearerConnectionTarget,11 BearerConnectionTarget,
8 ConnectionBlockedError,12 ConnectionBlockedError,
9 ConnectionTransientError,13 ConnectionTransientError,
14 RelayConnectionTarget,
10 type SupervisorConnectionState,15 type SupervisorConnectionState,
11} from "./model.ts";16} from "./model.ts";
12import {17import {
...@@ -72,25 +77,62 @@ describe("connection presentation", () => {...@@ -72,25 +77,62 @@ describe("connection presentation", () => {
72 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");77 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");
73 });78 });
7479
75 it("offers an MCP address only where an MCP client can sign in", () => {80 it("copies the MCP address of the route this device is connected over", () => {
76 expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp");81 const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => {
77 const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({82 const target = new BearerConnectionTarget({ ...TARGET, connectionId });
83 return {
84 target,
85 profile: Option.some(
86 new BearerConnectionProfile({
87 connectionId,
88 environmentId: TARGET.environmentId,
89 label: TARGET.label,
90 httpBaseUrl,
91 wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"),
92 }),
93 ),
94 };
95 };
96 const lan = route("lan", "http://192.168.4.53:3773/");
97 const tailnet = route("tailnet", "http://100.115.1.44:3773/");
98 const serve = route("serve", "https://machine.tailnet.ts.net/");
99 const entry: ConnectionCatalogEntry = {
78 ...ENTRY,100 ...ENTRY,
79 profile: Option.some(101 target: lan.target,
80 new BearerConnectionProfile({102 profile: lan.profile,
81 connectionId: TARGET.connectionId,103 alternateRoutes: [tailnet, serve],
82 environmentId: TARGET.environmentId,104 };
83 label: TARGET.label,105
84 httpBaseUrl,106 expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe(
85 wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"),107 "http://100.115.1.44:3773/mcp",
86 }),108 );
87 ),109 // Not connected: the preferred route, plain http or not.
110 expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp");
111 });
112
113 it("passes over routes without an address of their own", () => {
114 const relay = new RelayConnectionTarget({
115 environmentId: TARGET.environmentId,
116 label: TARGET.label,
88 });117 });
89 expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe(118 const entry: ConnectionCatalogEntry = {
90 "http://127.0.0.1:3773/mcp",119 ...ENTRY,
120 target: relay,
121 profile: Option.none(),
122 alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }],
123 };
124
125 // Relay discovery has not reported the tunnel address yet.
126 expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe(
127 "https://environment.example.test/mcp",
91 );128 );
92 // A plain-http LAN or tailnet address is refused by MCP clients' token checks.129 expect(
93 expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull();130 environmentMcpUrl({
131 entry,
132 connectedTarget: relay,
133 relayHttpBaseUrl: "https://tunnel.example.test",
134 }),
135 ).toBe("https://tunnel.example.test/mcp");
94 });136 });
95137
96 it("distinguishes initial connection, reconnect, and retry errors", () => {138 it("distinguishes initial connection, reconnect, and retry errors", () => {
packages/client-runtime/src/connection/presentation.ts+24-15
...@@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts";...@@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
33
4import type { ConnectionCatalogEntry } from "./catalog.ts";4import type { ConnectionCatalogEntry } from "./catalog.ts";
5import type { SupervisorConnectionState } from "./model.ts";5import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts";
6import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts";
67
7export type EnvironmentConnectionPhase =8export type EnvironmentConnectionPhase =
8 | "available"9 | "available"
...@@ -94,31 +95,39 @@ export function presentEnvironmentConnection(...@@ -94,31 +95,39 @@ export function presentEnvironmentConnection(
9495
95/**96/**
96 * The address an agent outside T3 (Claude Code, Codex) uses to reach this97 * The address an agent outside T3 (Claude Code, Codex) uses to reach this
97 * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP98 * environment's MCP server: the route this device is connected over, since an
98 * clients refuse to sign in through a plain-http token endpoint elsewhere.99 * agent beside this client can reach it too, else the first route in
99 * SSH connections ride a local forward that disappears with the client, so100 * preference order that has an address. SSH connections ride a local forward
100 * they have no stable address to hand out.101 * that disappears with the client, so they have no stable address.
101 */102 */
102export function environmentMcpUrl(input: {103export function environmentMcpUrl(input: {
103 readonly entry: ConnectionCatalogEntry;104 readonly entry: ConnectionCatalogEntry;
104 readonly relayHttpBaseUrl?: string | undefined;105 readonly relayHttpBaseUrl?: string | undefined;
106 readonly connectedTarget?: ConnectionTarget | null | undefined;
105}): string | null {107}): string | null {
106 const httpBaseUrl =108 const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null;
107 input.entry.target._tag === "RelayConnectionTarget"109 const routes = connectionRoutes(input.entry);
108 ? (input.relayHttpBaseUrl ?? null)110 const connectedRoute = routes.find(
109 : input.entry.target._tag === "SshConnectionTarget"111 (route) => connectionRouteId(route.target) === connectedRouteId,
110 ? null112 );
111 : connectionCatalogDisplayUrl(input.entry);113 for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) {
112 if (httpBaseUrl === null) return null;114 const httpBaseUrl =
115 route.target._tag === "RelayConnectionTarget"
116 ? (input.relayHttpBaseUrl ?? null)
117 : routeHttpBaseUrl(route);
118 const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl);
119 if (mcpUrl !== null) return mcpUrl;
120 }
121 return null;
122}
123
124function mcpUrlFromBase(httpBaseUrl: string): string | null {
113 let url: URL;125 let url: URL;
114 try {126 try {
115 url = new URL(httpBaseUrl);127 url = new URL(httpBaseUrl);
116 } catch {128 } catch {
117 return null;129 return null;
118 }130 }
119 const loopback =
120 url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
121 if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
122 url.pathname = "/mcp";131 url.pathname = "/mcp";
123 url.search = "";132 url.search = "";
124 url.hash = "";133 url.hash = "";