Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ function routesList(routes: ReadonlyArray<ConnectionRoute>) {
connection: { phase: "available", error: null, traceId: null },
serverConfig: null,
};
return <EnvironmentRoutesList environment={environment} onAddRoute={() => undefined} />;
return (
<EnvironmentRoutesList
environment={environment}
relayHttpBaseUrl={undefined}
onAddRoute={() => undefined}
onCopyMcpUrl={() => undefined}
/>
);
}

it("shows a route added after a reorder was saved, once another route is removed", async () => {
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/components/settings/EnvironmentRoutesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import {
connectionRouteAddress,
connectionRouteId,
connectionRouteLabel,
connectionRouteMcpUrl,
connectionRoutes,
isLearned,
pendingRouteOrder,
} from "@t3tools/client-runtime/connection";
import { GripVerticalIcon, PlusIcon, XIcon } from "lucide-react";
import { CopyIcon, GripVerticalIcon, PlusIcon, XIcon } from "lucide-react";
import { useState } from "react";

import { requestConfirmDialog } from "~/confirmDialog";
Expand All @@ -39,10 +40,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

export function EnvironmentRoutesList({
environment,
relayHttpBaseUrl,
onAddRoute,
onCopyMcpUrl,
}: {
readonly environment: EnvironmentPresentation;
readonly relayHttpBaseUrl: string | undefined;
readonly onAddRoute: () => void;
readonly onCopyMcpUrl: (url: string) => void;
}) {
const saved = connectionRoutes(environment.entry);
const savedIds = saved.map((route) => connectionRouteId(route.target));
Expand Down Expand Up @@ -108,7 +113,9 @@ export function EnvironmentRoutesList({
route={route}
position={index + 1}
inUse={connectionRouteId(route.target) === activeRouteId}
mcpUrl={connectionRouteMcpUrl(route, relayHttpBaseUrl)}
removable={routes.length > 1 && !isLearned(route)}
onCopyMcpUrl={onCopyMcpUrl}
onRemove={() => void confirmRemove(route)}
/>
))}
Expand All @@ -129,13 +136,17 @@ function SortableRouteRow({
route,
position,
inUse,
mcpUrl,
removable,
onCopyMcpUrl,
onRemove,
}: {
readonly route: ConnectionRoute;
readonly position: number;
readonly inUse: boolean;
readonly mcpUrl: string | null;
readonly removable: boolean;
readonly onCopyMcpUrl: (url: string) => void;
readonly onRemove: () => void;
}) {
const id = connectionRouteId(route.target);
Expand Down Expand Up @@ -176,6 +187,24 @@ function SortableRouteRow({
</p>
) : null}
</div>
{mcpUrl !== null ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost-muted"
size="icon-xs"
aria-label={`Copy ${label} MCP URL`}
onClick={() => onCopyMcpUrl(mcpUrl)}
/>
}
>
<CopyIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">Copy MCP URL</TooltipPopup>
</Tooltip>
) : null}
<button
type="button"
ref={setActivatorNodeRef}
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/settings/SavedEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,10 @@ export function SavedBackendListRow({
const diagnosis = resumingServerUpdate ? null : currentDiagnosis;
const prepared = usePreparedConnection(environmentId);
const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null;
const relayHttpBaseUrl = discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl;
const mcpUrl = environmentMcpUrl({
entry: environment.entry,
relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
relayHttpBaseUrl,
connectedTarget,
});
const machineKind = resolveEnvironmentMachineKind(
Expand Down Expand Up @@ -335,7 +336,9 @@ export function SavedBackendListRow({
{routesOpen && (
<EnvironmentRoutesList
environment={environment}
relayHttpBaseUrl={relayHttpBaseUrl}
onAddRoute={() => onAddRoute(environment)}
onCopyMcpUrl={(url) => copyMcpUrl(url, { url })}
/>
)}
{permissionsOpen && (
Expand Down
4 changes: 3 additions & 1 deletion docs/user/outside-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ https://<environment-address>/mcp

When T3 Connect serves the environment, the copied URL is its T3 Connect
address, which agents can reach from anywhere. Otherwise it uses the route this
device is connected over, so an agent on the same device can reach it.
device is connected over, so an agent on the same device can reach it. To copy
another address, open the environment's routes and use the copy button on that
route.

- **An agent on your own computers** can use `localhost` on the host, T3
Connect, or any `https` address, such as
Expand Down
30 changes: 30 additions & 0 deletions packages/client-runtime/src/connection/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import {
BearerConnectionTarget,
ConnectionTransientError,
RelayConnectionTarget,
SshConnectionTarget,
type SupervisorConnectionState,
} from "./model.ts";
import {
connectionRouteMcpUrl,
connectionStatusText,
connectionStatusTitle,
environmentMcpUrl,
Expand Down Expand Up @@ -119,6 +121,34 @@ describe("connection presentation", () => {
).toBe("https://tunnel.example.test/mcp");
});

it("gives each route its own MCP URL, or none when it has no stable address", () => {
const bearer: ConnectionRoute = { target: ENTRY.target, profile: ENTRY.profile };
const relay: ConnectionRoute = {
target: new RelayConnectionTarget({
environmentId: TARGET.environmentId,
label: TARGET.label,
}),
profile: Option.none(),
};
const ssh: ConnectionRoute = {
target: new SshConnectionTarget({
environmentId: TARGET.environmentId,
label: TARGET.label,
connectionId: "ssh",
}),
profile: Option.none(),
};

expect(connectionRouteMcpUrl(bearer, "https://tunnel.example.test")).toBe(
"https://environment.example.test/mcp",
);
expect(connectionRouteMcpUrl(relay, "https://tunnel.example.test")).toBe(
"https://tunnel.example.test/mcp",
);
expect(connectionRouteMcpUrl(relay, undefined)).toBeNull();
expect(connectionRouteMcpUrl(ssh, "https://tunnel.example.test")).toBeNull();
});

it("distinguishes initial connection, reconnect, and retry errors", () => {
expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({
phase: "connecting",
Expand Down
16 changes: 13 additions & 3 deletions packages/client-runtime/src/connection/presentation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ServerConfig } from "@t3tools/contracts";
import * as Option from "effect/Option";

import type { ConnectionCatalogEntry } from "./catalog.ts";
import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts";
import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts";

Expand Down Expand Up @@ -134,13 +134,23 @@ export function environmentMcpUrl(input: {
(route) => connectionRouteId(route.target) === connectedRouteId,
);
for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) {
const httpBaseUrl = routeHttpBaseUrl(route);
const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl);
const mcpUrl = connectionRouteMcpUrl(route, input.relayHttpBaseUrl);
if (mcpUrl !== null) return mcpUrl;
}
return null;
}

export function connectionRouteMcpUrl(
route: ConnectionRoute,
relayHttpBaseUrl: string | undefined,
): string | null {
const httpBaseUrl =
route.target._tag === "RelayConnectionTarget"
? (relayHttpBaseUrl ?? null)
: routeHttpBaseUrl(route);
return httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl);
}

function mcpUrlFromBase(httpBaseUrl: string): string | null {
let url: URL;
try {
Expand Down
Loading