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({
16651665 ) {
16661666 setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);
16671667 }
1668 const prepared = usePreparedConnection(environmentId);
1669 const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null;
16681670 const mcpUrl = environmentMcpUrl({
16691671 entry: environment.entry,
16701672 relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
1673 connectedTarget,
16711674 });
16721675 const machineKind = resolveEnvironmentMachineKind(
16731676 environment.serverConfig ??
16741677 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
16751678 );
1676 const prepared = usePreparedConnection(environmentId);
16771679 const routeCount = connectionRoutes(environment.entry).length;
16781680 const subtitleText = [
1679 environmentTransportLabel(
1680 environment,
1681 isConnected && prepared._tag === "Some" ? prepared.value.target : null,
1682 ),
1681 environmentTransportLabel(environment, connectedTarget),
16831682 resumingServerUpdate ? "Restarting" : status.text,
16841683 enabled && versionMismatch ? serverVersion : null,
16851684 ]
docs/user/outside-agents.md+5-4
......@@ -16,11 +16,12 @@ example:
1616https://<environment-address>/mcp
1717```
1818
19The address must use HTTPS, or `localhost` when the agent runs on the host
20itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address.
19The copied URL uses the route this device is connected over, so an agent on the
20same device can reach it.
2121
22- **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or
23 `localhost` on the host. See [remote access](./remote-access.md).
22- **An agent on your own computers** can use any address that computer reaches
23 the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on
24 the host. See [remote access](./remote-access.md).
2425- **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach
2526 the environment from the internet. Use the T3 Connect address. A Tailscale
2627 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";
22import { describe, expect, it } from "@effect/vitest";
33import * 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";
610import {
711 BearerConnectionTarget,
812 ConnectionBlockedError,
913 ConnectionTransientError,
14 RelayConnectionTarget,
1015 type SupervisorConnectionState,
1116} from "./model.ts";
1217import {
......@@ -72,25 +77,62 @@ describe("connection presentation", () => {
7277 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");
7378 });
7479
75 it("offers an MCP address only where an MCP client can sign in", () => {
76 expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp");
77 const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({
80 it("copies the MCP address of the route this device is connected over", () => {
81 const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => {
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 = {
78100 ...ENTRY,
79 profile: Option.some(
80 new BearerConnectionProfile({
81 connectionId: TARGET.connectionId,
82 environmentId: TARGET.environmentId,
83 label: TARGET.label,
84 httpBaseUrl,
85 wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"),
86 }),
87 ),
101 target: lan.target,
102 profile: lan.profile,
103 alternateRoutes: [tailnet, serve],
104 };
105
106 expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe(
107 "http://100.115.1.44:3773/mcp",
108 );
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,
88117 });
89 expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe(
90 "http://127.0.0.1:3773/mcp",
118 const entry: ConnectionCatalogEntry = {
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",
91128 );
92 // A plain-http LAN or tailnet address is refused by MCP clients' token checks.
93 expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull();
129 expect(
130 environmentMcpUrl({
131 entry,
132 connectedTarget: relay,
133 relayHttpBaseUrl: "https://tunnel.example.test",
134 }),
135 ).toBe("https://tunnel.example.test/mcp");
94136 });
95137
96138 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";
22import * as Option from "effect/Option";
33
44import 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
78export type EnvironmentConnectionPhase =
89 | "available"
......@@ -94,31 +95,39 @@ export function presentEnvironmentConnection(
9495
9596/**
9697 * The address an agent outside T3 (Claude Code, Codex) uses to reach this
97 * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP
98 * clients refuse to sign in through a plain-http token endpoint elsewhere.
99 * SSH connections ride a local forward that disappears with the client, so
100 * they have no stable address to hand out.
98 * environment's MCP server: the route this device is connected over, since an
99 * agent beside this client can reach it too, else the first route in
100 * preference order that has an address. SSH connections ride a local forward
101 * that disappears with the client, so they have no stable address.
101102 */
102103export function environmentMcpUrl(input: {
103104 readonly entry: ConnectionCatalogEntry;
104105 readonly relayHttpBaseUrl?: string | undefined;
106 readonly connectedTarget?: ConnectionTarget | null | undefined;
105107}): string | null {
106 const httpBaseUrl =
107 input.entry.target._tag === "RelayConnectionTarget"
108 ? (input.relayHttpBaseUrl ?? null)
109 : input.entry.target._tag === "SshConnectionTarget"
110 ? null
111 : connectionCatalogDisplayUrl(input.entry);
112 if (httpBaseUrl === null) return null;
108 const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null;
109 const routes = connectionRoutes(input.entry);
110 const connectedRoute = routes.find(
111 (route) => connectionRouteId(route.target) === connectedRouteId,
112 );
113 for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) {
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 {
113125 let url: URL;
114126 try {
115127 url = new URL(httpBaseUrl);
116128 } catch {
117129 return null;
118130 }
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;
122131 url.pathname = "/mcp";
123132 url.search = "";
124133 url.hash = "";