Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
RelayConnectionTarget,
connectionRoutes,
connectionStatusText,
environmentMcpUrl,
} from "@t3tools/client-runtime/connection";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -1526,6 +1527,25 @@ function SavedBackendListRow({
},
[copyTraceIdToClipboard],
);
const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({
target: "MCP URL",
onCopy: ({ url }) => {
toastManager.add({
type: "success",
title: "MCP URL copied",
description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/ConnectionsSettings.tsx:1536

The success toast displays the MCP URL verbatim, so saved bearer connections with URL userinfo expose credentials such as user:password in the UI; the same credential-bearing URL is also copied. Strip username and password when constructing mcpUrl (for example in environmentMcpUrl) before both displaying and copying it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around line 1536:

The success toast displays the MCP URL verbatim, so saved bearer connections with URL userinfo expose credentials such as `user:password` in the UI; the same credential-bearing URL is also copied. Strip `username` and `password` when constructing `mcpUrl` (for example in `environmentMcpUrl`) before both displaying and copying it.

});
},
onError: (error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not copy MCP URL",
description: error.message,
}),
);
},
});
const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
const resumingServerUpdate =
Expand All @@ -1545,6 +1565,20 @@ function SavedBackendListRow({
if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) {
setLastDescriptor(discoveredDescriptor);
}
// Held for the same reason as the descriptor, so Copy MCP URL survives a refresh.
const discoveredRelayHttpBaseUrl =
relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl;
const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl);
if (
discoveredRelayHttpBaseUrl !== undefined &&
discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl
) {
setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);
}
const mcpUrl = environmentMcpUrl({
entry: environment.entry,
relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
});
Comment thread
juliusmarminge marked this conversation as resolved.
const machineKind = resolveEnvironmentMachineKind(
environment.serverConfig ??
(lastDescriptor === undefined ? null : { environment: lastDescriptor }),
Expand Down Expand Up @@ -1711,6 +1745,9 @@ function SavedBackendListRow({
<RouteIcon />
{routesOpen ? "Hide routes" : "Routes"}
</MenuItem>
{mcpUrl ? (
<MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem>
) : null}
{errorTraceId ? (
<MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
) : null}
Expand Down
28 changes: 28 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,34 @@ running is left alone.
For Antigravity's Google callback on a remote host, see
[remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device).

## Connect an outside agent

An agent T3 Code did not start, such as Claude Code in your own terminal, can
drive threads on an environment through its MCP server. In **Settings →
Connections**, open a saved environment's menu and choose **Copy MCP URL**, then
add it to the agent. For example:

```sh
claude mcp add --transport http t3 https://<environment-address>/mcp
```

The first time the agent connects, it opens a sign-in page on the environment.
Enter a pairing code from **Settings → Connections** on a device that can manage
access, or from `t3 auth pairing create` on the host, and choose what the agent
may do. A browser already signed in to that environment as an administrator can
approve without a code.

- **Read only** lets the agent read projects and threads in every project, and
see which providers and models are available. It cannot change anything.
- **Supervised** through **Full access** also let it start, message and stop
threads in every project, but it cannot start or steer a thread with more
permissions than the mode you chose.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Use an HTTPS address: T3 Connect, Tailscale Serve, or `localhost` on the host
itself. Agents refuse to sign in through a plain `http://` LAN or tailnet
address. The agent appears under **Settings → Connections** like any other
client; revoke it there. Sign-ins last 30 days.

## Manage or revoke access

On the host, **Settings → Connections** lets authorized administrators create
Expand Down
22 changes: 22 additions & 0 deletions packages/client-runtime/src/connection/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./model.ts";
import {
connectionCatalogDisplayUrl,
environmentMcpUrl,
connectionStatusText,
connectionStatusTitle,
presentEnvironmentConnection,
Expand Down Expand Up @@ -71,6 +72,27 @@ 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 => ({
...ENTRY,
profile: Option.some(
new BearerConnectionProfile({
connectionId: TARGET.connectionId,
environmentId: TARGET.environmentId,
label: TARGET.label,
httpBaseUrl,
wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"),
}),
),
});
expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe(
"http://127.0.0.1:3773/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();
});

it("distinguishes initial connection, reconnect, and retry errors", () => {
expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({
phase: "connecting",
Expand Down
33 changes: 33 additions & 0 deletions packages/client-runtime/src/connection/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ export function presentEnvironmentConnection(
return presentConnectionState(state);
}

/**
* 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.
*/
export function environmentMcpUrl(input: {
readonly entry: ConnectionCatalogEntry;
readonly relayHttpBaseUrl?: string | 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;
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 = "";
return url.toString();
}

export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {
switch (entry.target._tag) {
case "PrimaryConnectionTarget":
Expand Down
Loading