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 {...@@ -48,6 +48,7 @@ import {
48 RelayConnectionTarget,48 RelayConnectionTarget,
49 connectionRoutes,49 connectionRoutes,
50 connectionStatusText,50 connectionStatusText,
51 environmentMcpUrl,
51} from "@t3tools/client-runtime/connection";52} from "@t3tools/client-runtime/connection";
52import {53import {
53 isAtomCommandInterrupted,54 isAtomCommandInterrupted,
...@@ -1526,6 +1527,25 @@ function SavedBackendListRow({...@@ -1526,6 +1527,25 @@ function SavedBackendListRow({
1526 },1527 },
1527 [copyTraceIdToClipboard],1528 [copyTraceIdToClipboard],
1528 );1529 );
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 });
1529 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);1549 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
1530 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));1550 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
1531 const resumingServerUpdate =1551 const resumingServerUpdate =
...@@ -1545,6 +1565,20 @@ function SavedBackendListRow({...@@ -1545,6 +1565,20 @@ function SavedBackendListRow({
1545 if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) {1565 if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) {
1546 setLastDescriptor(discoveredDescriptor);1566 setLastDescriptor(discoveredDescriptor);
1547 }1567 }
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 });
1548 const machineKind = resolveEnvironmentMachineKind(1582 const machineKind = resolveEnvironmentMachineKind(
1549 environment.serverConfig ??1583 environment.serverConfig ??
1550 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),1584 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
...@@ -1711,6 +1745,9 @@ function SavedBackendListRow({...@@ -1711,6 +1745,9 @@ function SavedBackendListRow({
1711 <RouteIcon />1745 <RouteIcon />
1712 {routesOpen ? "Hide routes" : "Routes"}1746 {routesOpen ? "Hide routes" : "Routes"}
1713 </MenuItem>1747 </MenuItem>
1748 {mcpUrl ? (
1749 <MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem>
1750 ) : null}
1714 {errorTraceId ? (1751 {errorTraceId ? (
1715 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>1752 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1716 ) : null}1753 ) : null}
docs/user/remote-access.md+28
...@@ -220,6 +220,34 @@ only reports what it would change....@@ -220,6 +220,34 @@ only reports what it would change.
220The browser always runs in Chrome's sandbox. Where you cannot change the host,220The browser always runs in Chrome's sandbox. Where you cannot change the host,
221set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it.221set `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
223## Manage or revoke access251## Manage or revoke access
224252
225On the host, **Settings → Connections** lets authorized administrators create253On the host, **Settings → Connections** lets authorized administrators create
packages/client-runtime/src/connection/presentation.test.ts+22
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11} from "./model.ts";11} from "./model.ts";
12import {12import {
13 connectionCatalogDisplayUrl,13 connectionCatalogDisplayUrl,
14 environmentMcpUrl,
14 connectionStatusText,15 connectionStatusText,
15 connectionStatusTitle,16 connectionStatusTitle,
16 presentEnvironmentConnection,17 presentEnvironmentConnection,
...@@ -71,6 +72,27 @@ describe("connection presentation", () => {...@@ -71,6 +72,27 @@ describe("connection presentation", () => {
71 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");72 expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test");
72 });73 });
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
74 it("distinguishes initial connection, reconnect, and retry errors", () => {96 it("distinguishes initial connection, reconnect, and retry errors", () => {
75 expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({97 expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({
76 phase: "connecting",98 phase: "connecting",
packages/client-runtime/src/connection/presentation.ts+33
...@@ -92,6 +92,39 @@ export function presentEnvironmentConnection(...@@ -92,6 +92,39 @@ export function presentEnvironmentConnection(
92 return presentConnectionState(state);92 return presentConnectionState(state);
93}93}
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
95export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {128export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {
96 switch (entry.target._tag) {129 switch (entry.target._tag) {
97 case "PrimaryConnectionTarget":130 case "PrimaryConnectionTarget":