authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 14:09:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 14:09:36-07:00
logfbe5df2d4b630d13adc8fe2d38cab354e6d66d67
tree25bb6c9de44a26b632d1c9920ea5c059357113ba
parent2c8be5893eb3754071162e344a8c8e8a67bef597
signature Signed by PGP key B5690EEEBB952194

feat(web): copy an environment's MCP URL for outside agents (#16337)

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

4 files changed, 120 insertions(+), 0 deletions(-)

apps/web/src/components/settings/ConnectionsSettings.tsx+37
......@@ -48,6 +48,7 @@ import {
4848 RelayConnectionTarget,
4949 connectionRoutes,
5050 connectionStatusText,
51 environmentMcpUrl,
5152} from "@t3tools/client-runtime/connection";
5253import {
5354 isAtomCommandInterrupted,
......@@ -1526,6 +1527,25 @@ function SavedBackendListRow({
15261527 },
15271528 [copyTraceIdToClipboard],
15281529 );
1530 const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({
1531 target: "MCP URL",
1532 onCopy: ({ url }) => {
1533 toastManager.add({
1534 type: "success",
1535 title: "MCP URL copied",
1536 description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`,
1537 });
1538 },
1539 onError: (error) => {
1540 toastManager.add(
1541 stackedThreadToast({
1542 type: "error",
1543 title: "Could not copy MCP URL",
1544 description: error.message,
1545 }),
1546 );
1547 },
1548 });
15291549 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
15301550 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
15311551 const resumingServerUpdate =
......@@ -1545,6 +1565,20 @@ function SavedBackendListRow({
15451565 if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) {
15461566 setLastDescriptor(discoveredDescriptor);
15471567 }
1568 // Held for the same reason as the descriptor, so Copy MCP URL survives a refresh.
1569 const discoveredRelayHttpBaseUrl =
1570 relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl;
1571 const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl);
1572 if (
1573 discoveredRelayHttpBaseUrl !== undefined &&
1574 discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl
1575 ) {
1576 setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);
1577 }
1578 const mcpUrl = environmentMcpUrl({
1579 entry: environment.entry,
1580 relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
1581 });
15481582 const machineKind = resolveEnvironmentMachineKind(
15491583 environment.serverConfig ??
15501584 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
......@@ -1711,6 +1745,9 @@ function SavedBackendListRow({
17111745 <RouteIcon />
17121746 {routesOpen ? "Hide routes" : "Routes"}
17131747 </MenuItem>
1748 {mcpUrl ? (
1749 <MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem>
1750 ) : null}
17141751 {errorTraceId ? (
17151752 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
17161753 ) : null}
docs/user/remote-access.md+28
......@@ -220,6 +220,34 @@ only reports what it would change.
220220The browser always runs in Chrome's sandbox. Where you cannot change the host,
221221set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it.
222222
223## Connect an outside agent
224
225An agent T3 Code did not start, such as Claude Code in your own terminal, can
226drive threads on an environment through its MCP server. In **Settings →
227Connections**, open a saved environment's menu and choose **Copy MCP URL**, then
228add it to the agent. For example:
229
230```sh
231claude mcp add --transport http t3 https://<environment-address>/mcp
232```
233
234The first time the agent connects, it opens a sign-in page on the environment.
235Enter a pairing code from **Settings → Connections** on a device that can manage
236access, or from `t3 auth pairing create` on the host, and choose what the agent
237may do. A browser already signed in to that environment as an administrator can
238approve without a code.
239
240- **Read only** lets the agent read projects and threads in every project, and
241 see which providers and models are available. It cannot change anything.
242- **Supervised** through **Full access** also let it start, message and stop
243 threads in every project, but it cannot start or steer a thread with more
244 permissions than the mode you chose.
245
246Use an HTTPS address: T3 Connect, Tailscale Serve, or `localhost` on the host
247itself. Agents refuse to sign in through a plain `http://` LAN or tailnet
248address. The agent appears under **Settings → Connections** like any other
249client; revoke it there. Sign-ins last 30 days.
250
223251## Manage or revoke access
224252
225253On the host, **Settings → Connections** lets authorized administrators create
packages/client-runtime/src/connection/presentation.test.ts+22
......@@ -11,6 +11,7 @@ import {
1111} from "./model.ts";
1212import {
1313 connectionCatalogDisplayUrl,
14 environmentMcpUrl,
1415 connectionStatusText,
1516 connectionStatusTitle,
1617 presentEnvironmentConnection,
......@@ -71,6 +72,27 @@ describe("connection presentation", () => {
7172 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");
7273 });
7374
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 => ({
78 ...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 ),
88 });
89 expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe(
90 "http://127.0.0.1:3773/mcp",
91 );
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();
94 });
95
7496 it("distinguishes initial connection, reconnect, and retry errors", () => {
7597 expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({
7698 phase: "connecting",
packages/client-runtime/src/connection/presentation.ts+33
......@@ -92,6 +92,39 @@ export function presentEnvironmentConnection(
9292 return presentConnectionState(state);
9393}
9494
95/**
96 * 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.
101 */
102export function environmentMcpUrl(input: {
103 readonly entry: ConnectionCatalogEntry;
104 readonly relayHttpBaseUrl?: string | undefined;
105}): 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;
113 let url: URL;
114 try {
115 url = new URL(httpBaseUrl);
116 } catch {
117 return null;
118 }
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";
123 url.search = "";
124 url.hash = "";
125 return url.toString();
126}
127
95128export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {
96129 switch (entry.target._tag) {
97130 case "PrimaryConnectionTarget":