From f644e7fb6cf0bc1e6b7a93e9b317cf9cb2b8091c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 17:37:58 +0000 Subject: [PATCH 01/13] fix(web,server): this machine's menu copies the MCP URL its tunnel serves Settings > Connections had no Copy MCP URL for the local machine, so the only one on offer sat on a saved T3 Connect row with the same name. That row is a different environment, and when its tunnel is gone the copied host answers Cloudflare 1033. The server's T3 Connect check now reports the host the running tunnel answered on. The local machine's menu and `t3 connect status` build the MCP URL from it. Refs #23709 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/connect.ts | 6 ++ .../src/cloud/ConnectReachability.test.ts | 13 +++- apps/server/src/cloud/ConnectReachability.ts | 1 + .../settings/ConnectionsSettings.tsx | 2 + .../settings/SavedEnvironmentRows.tsx | 61 +++++++++++++------ docs/user/outside-agents.md | 7 +++ packages/contracts/src/environmentHttp.ts | 13 ++++ 7 files changed, 83 insertions(+), 20 deletions(-) diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 76644be57e14a..9e3ddc8576c63 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -2,6 +2,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, type AuthEnvironmentScope, + connectReachableMcpUrl, type EnvironmentConnectReachability, EnvironmentHttpApi, formatConnectReachable, @@ -199,6 +200,10 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b : !status.linked ? "Start T3 to provision the environment link and launch its managed tunnel." : undefined; + const mcpUrl = + status.reachability?.status === "reachable" + ? connectReachableMcpUrl(status.reachability) + : null; return [ "T3 Connect", @@ -219,6 +224,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b ? [ "", `✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`, + ...(mcpUrl ? [` MCP URL for outside agents: ${mcpUrl}`] : []), ] : ["", formatReachabilityFailure(status.reachability)]), ...(nextStep ? ["", `Next: ${nextStep}`] : []), diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index 28735b87378f7..a474edeaba5fb 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - reads the user guide to check its anchors. import * as NodeFS from "node:fs"; -import { T3_CONNECT_TROUBLESHOOTING_SECTIONS } from "@t3tools/contracts"; +import { T3_CONNECT_TROUBLESHOOTING_SECTIONS, connectReachableMcpUrl } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { classifyReachability, type ReachabilityFacts } from "./ConnectReachability.ts"; @@ -60,6 +60,17 @@ describe("classifyReachability", () => { ).toMatchObject({ cause: "dns_failed" }); }); + it("gives the MCP URL on the host the running tunnel answered on", () => { + const result = classifyReachability( + facts({ endpoint: { kind: "status", status: 200 } }), + checkedAt, + ); + expect(result).toMatchObject({ status: "reachable", hostname: "env.example.test" }); + expect(result.status === "reachable" && connectReachableMcpUrl(result)).toBe( + "https://env.example.test/mcp", + ); + }); + it("waits on a connector that has not connected while the edge answers", () => { expect( classifyReachability(facts({ connector: notConnected, edgeReachable: true }), checkedAt), diff --git a/apps/server/src/cloud/ConnectReachability.ts b/apps/server/src/cloud/ConnectReachability.ts index 17e78f4be1783..4bc76b991f311 100644 --- a/apps/server/src/cloud/ConnectReachability.ts +++ b/apps/server/src/cloud/ConnectReachability.ts @@ -88,6 +88,7 @@ export function classifyReachability( checkedAt, readyConnections: connector.readyConnections, protocol: connector.protocol, + ...(connector.hostname ? { hostname: connector.hostname } : {}), }; } // The edge test is TCP only. With QUIC connections up, a 502 from a working tunnel, or diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3639c921f3568..dd1328c7c2f3d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -130,6 +130,7 @@ import { import { CloudLinkRow, CloudRemoteEnvironmentRows, + PrimaryMcpUrlMenuItem, SavedBackendListRow, } from "./SavedEnvironmentRows"; import { AddEnvironmentDialog } from "./AddEnvironmentDialog"; @@ -1069,6 +1070,7 @@ export function ConnectionsSettings() { environmentId={primaryEnvironmentId} serverConfig={primaryServerConfig} /> + ) : null diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index dbb7c3b94827e..d8c74225e820d 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -11,6 +11,7 @@ import { AuthRelayWriteScope, type EnvironmentId, connectReachabilityHelpUrl, + connectReachableMcpUrl, formatConnectReachable, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; @@ -48,6 +49,7 @@ import { supportsServerUpdateThreadContinuation, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { environmentCatalog } from "~/connection/catalog"; import { readEnvironmentScope } from "~/state/session"; @@ -105,6 +107,45 @@ function savedBackendStatus(environment: EnvironmentPresentation): { } } +function useCopyMcpUrl() { + return 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}`, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy MCP URL", + description: error.message, + }), + ); + }, + }).copyToClipboard; +} + +/** + * This machine's T3 Connect MCP URL, from the host its running tunnel answered on. Shown only + * after a passing check, so it never hands out an address nothing serves. + */ +export function PrimaryMcpUrlMenuItem() { + const { data } = usePrimaryCloudLinkState(); + const copyMcpUrl = useCopyMcpUrl(); + const reachability = + data && data.managedTunnelActive !== false && data.linkRevoked !== true + ? data.reachability + : undefined; + const mcpUrl = reachability?.status === "reachable" ? connectReachableMcpUrl(reachability) : null; + return mcpUrl ? ( + copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL + ) : null; +} + export function SavedBackendListRow({ environment, removingEnvironmentId, @@ -144,25 +185,7 @@ export function SavedBackendListRow({ const copyTraceId = (traceId: string) => { copyTraceIdToClipboard(traceId, { traceId }); }; - 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}`, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not copy MCP URL", - description: error.message, - }), - ); - }, - }); + const copyMcpUrl = useCopyMcpUrl(); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f1503ef49b227..3f80d791e8a1c 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -19,6 +19,13 @@ https:///mcp The copied URL uses the route this device is connected over, so an agent on the same device can reach it. +For the machine you are on, open the menu next to its name at the top of +**Settings → Connections** instead. Once T3 Connect has checked that other +devices can reach it, **Copy MCP URL** there gives the T3 Connect address its +tunnel is serving. `t3 connect status` prints the same URL. A saved environment +with the same name is a different environment, often an older install whose +tunnel is gone. + - **An agent on your own computers** can use `localhost` on the host, T3 Connect, or any `https` address, such as [Tailscale HTTPS](./remote-access.md#tailscale-https). Signing in sends the diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 87edc7d664a40..b3964dccbcecb 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -429,6 +429,8 @@ export const EnvironmentConnectReachability = Schema.Union([ /** Null when cloudflared did not report a count. */ readyConnections: Schema.NullOr(Schema.Int), protocol: Schema.NullOr(Schema.String), + /** The tunnel hostname that answered. Older servers leave it out. */ + hostname: Schema.optional(Schema.String), }), Schema.Struct({ status: Schema.Literal("unreachable"), @@ -453,6 +455,17 @@ export function formatConnectReachable( ].join(" · "); } +/** + * The MCP URL outside agents use to reach this environment through T3 Connect. It comes from + * the host the running tunnel answered on, since a saved T3 Connect entry with the same name + * can belong to an older environment whose tunnel is gone. + */ +export function connectReachableMcpUrl( + result: Extract, +): string | null { + return result.hostname ? `https://${result.hostname}/mcp` : null; +} + /** * Sections of docs/user/t3-connect-troubleshooting.md. Each is the GitHub anchor of a * heading there; the test fails when a heading is renamed without updating this list. From bef45aa4f349921438335ee8922c70861d928b33 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 17:46:55 +0000 Subject: [PATCH 02/13] fix(server): a relink no longer shows the old tunnel's MCP URL The reachability result is kept until the next check. A stopped connector sends no readiness event, so after relinking to another account the old result, with the old host, stayed visible until the new connector reported in. Each result now remembers the tunnel config it checked and is dropped once that config changes. Also limit the docs' `t3 connect status` claim to servers set up with `t3 connect`. Co-Authored-By: Claude Opus 5.5 --- .../src/cloud/ConnectReachability.test.ts | 69 ++++++++++++++++++- apps/server/src/cloud/ConnectReachability.ts | 34 ++++++--- docs/user/outside-agents.md | 6 +- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index a474edeaba5fb..205c42cf48335 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -3,8 +3,22 @@ import * as NodeFS from "node:fs"; import { T3_CONNECT_TROUBLESHOOTING_SECTIONS, connectReachableMcpUrl } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; -import { classifyReachability, type ReachabilityFacts } from "./ConnectReachability.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { CLOUD_ENDPOINT_RUNTIME_CONFIG } from "./config.ts"; +import { + ConnectReachability, + classifyReachability, + layer as connectReachabilityLayer, + type ReachabilityFacts, +} from "./ConnectReachability.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const checkedAt = "2026-10-19T00:00:00.000Z"; const connected = { readyConnections: 4, hostname: "env.example.test", protocol: "quic" }; @@ -78,6 +92,59 @@ describe("classifyReachability", () => { }); }); +describe("ConnectReachability", () => { + it.effect("drops a result once the tunnel config it checked is replaced", () => { + const secrets = new Map([[CLOUD_ENDPOINT_RUNTIME_CONFIG, new TextEncoder().encode("first")]]); + const unused = Effect.die("unused"); + const dependencies = Layer.mergeAll( + Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromNullishOr(secrets.get(name))), + set: () => unused, + create: () => unused, + getOrCreateRandom: () => unused, + remove: () => unused, + }), + ), + Layer.succeed( + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: () => unused, + inspectConnector: Effect.succeed(connected), + readinessChanged: Stream.empty, + recoveryRequests: Stream.empty, + requestRecovery: () => unused, + tunnelConnected: Stream.empty, + withLinkStateLock: (effect) => effect, + }), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}"))), + ), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => unused), + ), + ); + + return Effect.gen(function* () { + const reachability = yield* ConnectReachability; + yield* reachability.check; + expect(Option.getOrNull(yield* reachability.latest)).toMatchObject({ + hostname: "env.example.test", + }); + + // A relink stores a new tunnel before its connector reports in. + secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, new TextEncoder().encode("second")); + expect(yield* reachability.latest).toEqual(Option.none()); + }).pipe(Effect.provide(connectReachabilityLayer.pipe(Layer.provide(dependencies)))); + }); +}); + describe("T3 Connect troubleshooting links", () => { it("each section names a heading in the user guide", () => { const guide = NodeFS.readFileSync( diff --git a/apps/server/src/cloud/ConnectReachability.ts b/apps/server/src/cloud/ConnectReachability.ts index 4bc76b991f311..b1a036bae2ccf 100644 --- a/apps/server/src/cloud/ConnectReachability.ts +++ b/apps/server/src/cloud/ConnectReachability.ts @@ -175,6 +175,7 @@ function hasErrorCode(error: unknown, codes: ReadonlySet): boolean { } const DNS_ERROR_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_NONAME"]); +const textDecoder = new TextDecoder(); const canOpenTcp = (host: string, port: number) => Effect.callback((resume) => { @@ -203,8 +204,14 @@ const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const httpClient = yield* HttpClient.HttpClient; // `order` ranks checks by start, so a slow older one cannot replace a newer result. + // `tunnelConfig` is the config a result was checked against: after a relink the old + // result names the old tunnel's host until the new connector triggers a check. const latestRef = yield* Ref.make( - Option.none<{ readonly order: number; readonly result: EnvironmentConnectReachability }>(), + Option.none<{ + readonly order: number; + readonly result: EnvironmentConnectReachability; + readonly tunnelConfig: string | null; + }>(), ); const checkCounter = yield* Ref.make(0); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -226,10 +233,14 @@ const make = Effect.gen(function* () { ), ); - const gatherFacts = Effect.gen(function* () { - const managedTunnelConfigured = Option.isSome( - yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG).pipe(Effect.orElseSucceed(Option.none)), - ); + const readTunnelConfig = secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG).pipe( + Effect.map(Option.match({ onNone: () => null, onSome: (bytes) => textDecoder.decode(bytes) })), + Effect.orElseSucceed(() => null), + ); + + // Runs only inside the `ConnectReachability.check` span. + const gatherFacts = Effect.fnUntraced(function* (tunnelConfig: string | null) { + const managedTunnelConfigured = tunnelConfig !== null; const connector = managedTunnelConfigured ? yield* runtime.inspectConnector : null; const endpoint = connector?.hostname && connector.readyConnections !== 0 @@ -259,12 +270,13 @@ const make = Effect.gen(function* () { const check = Effect.gen(function* () { const order = yield* Ref.updateAndGet(checkCounter, (count) => count + 1); - const facts = yield* gatherFacts; + const tunnelConfig = yield* readTunnelConfig; + const facts = yield* gatherFacts(tunnelConfig); const result = classifyReachability(facts, DateTime.formatIso(yield* DateTime.now)); const previous = yield* Ref.modify(latestRef, (current) => Option.isSome(current) && current.value.order > order ? [null, current] - : [current, Option.some({ order, result })], + : [current, Option.some({ order, result, tunnelConfig })], ); if (previous === null) return result; const changed = Option.match(previous, { @@ -306,7 +318,13 @@ const make = Effect.gen(function* () { return ConnectReachability.of({ check, - latest: Ref.get(latestRef).pipe(Effect.map(Option.map(({ result }) => result))), + latest: Effect.gen(function* () { + const latest = yield* Ref.get(latestRef); + const tunnelConfig = yield* readTunnelConfig; + return Option.flatMap(latest, (entry) => + entry.tunnelConfig === tunnelConfig ? Option.some(entry.result) : Option.none(), + ); + }), }); }); diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 3f80d791e8a1c..70c38196e93f6 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -22,9 +22,9 @@ same device can reach it. For the machine you are on, open the menu next to its name at the top of **Settings → Connections** instead. Once T3 Connect has checked that other devices can reach it, **Copy MCP URL** there gives the T3 Connect address its -tunnel is serving. `t3 connect status` prints the same URL. A saved environment -with the same name is a different environment, often an older install whose -tunnel is gone. +tunnel is serving. On a server set up with `t3 connect`, `t3 connect status` +prints the same URL. A saved environment with the same name is a different +environment, often an older install whose tunnel is gone. - **An agent on your own computers** can use `localhost` on the host, T3 Connect, or any `https` address, such as From 4e7061b58d3280f72303f8cbdd3d9a2f60b5167e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 17:52:04 +0000 Subject: [PATCH 03/13] test(server): import ConnectReachability as a namespace Co-Authored-By: Claude Opus 5.5 --- .../src/cloud/ConnectReachability.test.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index 205c42cf48335..eb2b90ea5eb92 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -12,18 +12,15 @@ import { ChildProcessSpawner } from "effect/process"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG } from "./config.ts"; -import { - ConnectReachability, - classifyReachability, - layer as connectReachabilityLayer, - type ReachabilityFacts, -} from "./ConnectReachability.ts"; +import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const checkedAt = "2026-10-19T00:00:00.000Z"; const connected = { readyConnections: 4, hostname: "env.example.test", protocol: "quic" }; const notConnected = { readyConnections: 0, hostname: null, protocol: null }; -const facts = (overrides: Partial): ReachabilityFacts => ({ +const facts = ( + overrides: Partial, +): ConnectReachability.ReachabilityFacts => ({ managedTunnelConfigured: true, connector: connected, endpoint: null, @@ -38,21 +35,21 @@ describe("classifyReachability", () => { [{ kind: "status", status: 530 }, "no_connector"], [{ kind: "network_error" }, "timeout"], ] as const)("maps endpoint result %o to %s", (endpoint, cause) => { - expect(classifyReachability(facts({ endpoint, edgeReachable: true }), checkedAt)).toMatchObject( - { status: "unreachable", cause }, - ); + expect( + ConnectReachability.classifyReachability(facts({ endpoint, edgeReachable: true }), checkedAt), + ).toMatchObject({ status: "unreachable", cause }); }); it("keeps the endpoint cause when the TCP edge test cannot explain it", () => { // QUIC (UDP) can connect while TCP 7844 is blocked. expect( - classifyReachability( + ConnectReachability.classifyReachability( facts({ endpoint: { kind: "status", status: 502 }, edgeReachable: false }), checkedAt, ), ).toMatchObject({ cause: "origin_unreachable" }); expect( - classifyReachability( + ConnectReachability.classifyReachability( facts({ connector: { readyConnections: null, hostname: "env.example.test", protocol: null }, endpoint: { kind: "status", status: 502 }, @@ -63,7 +60,7 @@ describe("classifyReachability", () => { ).toMatchObject({ cause: "origin_unreachable" }); // Failed DNS also fails the edge lookups. expect( - classifyReachability( + ConnectReachability.classifyReachability( facts({ connector: { readyConnections: null, hostname: "env.example.test", protocol: null }, endpoint: { kind: "dns_failed" }, @@ -75,7 +72,7 @@ describe("classifyReachability", () => { }); it("gives the MCP URL on the host the running tunnel answered on", () => { - const result = classifyReachability( + const result = ConnectReachability.classifyReachability( facts({ endpoint: { kind: "status", status: 200 } }), checkedAt, ); @@ -87,7 +84,10 @@ describe("classifyReachability", () => { it("waits on a connector that has not connected while the edge answers", () => { expect( - classifyReachability(facts({ connector: notConnected, edgeReachable: true }), checkedAt), + ConnectReachability.classifyReachability( + facts({ connector: notConnected, edgeReachable: true }), + checkedAt, + ), ).toMatchObject({ cause: "connector_not_ready" }); }); }); @@ -132,7 +132,7 @@ describe("ConnectReachability", () => { ); return Effect.gen(function* () { - const reachability = yield* ConnectReachability; + const reachability = yield* ConnectReachability.ConnectReachability; yield* reachability.check; expect(Option.getOrNull(yield* reachability.latest)).toMatchObject({ hostname: "env.example.test", @@ -141,7 +141,7 @@ describe("ConnectReachability", () => { // A relink stores a new tunnel before its connector reports in. secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, new TextEncoder().encode("second")); expect(yield* reachability.latest).toEqual(Option.none()); - }).pipe(Effect.provide(connectReachabilityLayer.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.provide(ConnectReachability.layer.pipe(Layer.provide(dependencies)))); }); }); From 0d2037c8343c5d32e13f30df2fdcf9f042000e04 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 12:24:44 +0000 Subject: [PATCH 04/13] fix(web): hide the MCP URL while link settings refresh Consolidate MCP URL and relink coverage into one regression test and replace manual service stubs with Layer.mock. --- .../src/cloud/ConnectReachability.test.ts | 60 +++++-------------- .../settings/SavedEnvironmentRows.tsx | 8 +-- packages/contracts/src/environmentHttp.ts | 5 -- 3 files changed, 17 insertions(+), 56 deletions(-) diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index eb2b90ea5eb92..5972ff4a3bf13 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -11,7 +11,6 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { CLOUD_ENDPOINT_RUNTIME_CONFIG } from "./config.ts"; import * as ConnectReachability from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; @@ -71,17 +70,6 @@ describe("classifyReachability", () => { ).toMatchObject({ cause: "dns_failed" }); }); - it("gives the MCP URL on the host the running tunnel answered on", () => { - const result = ConnectReachability.classifyReachability( - facts({ endpoint: { kind: "status", status: 200 } }), - checkedAt, - ); - expect(result).toMatchObject({ status: "reachable", hostname: "env.example.test" }); - expect(result.status === "reachable" && connectReachableMcpUrl(result)).toBe( - "https://env.example.test/mcp", - ); - }); - it("waits on a connector that has not connected while the edge answers", () => { expect( ConnectReachability.classifyReachability( @@ -93,53 +81,35 @@ describe("classifyReachability", () => { }); describe("ConnectReachability", () => { - it.effect("drops a result once the tunnel config it checked is replaced", () => { - const secrets = new Map([[CLOUD_ENDPOINT_RUNTIME_CONFIG, new TextEncoder().encode("first")]]); - const unused = Effect.die("unused"); + it.effect("exposes the checked tunnel's MCP URL until its config changes", () => { + let config = new TextEncoder().encode("first"); const dependencies = Layer.mergeAll( - Layer.succeed( - ServerSecretStore.ServerSecretStore, - ServerSecretStore.ServerSecretStore.of({ - get: (name) => Effect.sync(() => Option.fromNullishOr(secrets.get(name))), - set: () => unused, - create: () => unused, - getOrCreateRandom: () => unused, - remove: () => unused, - }), - ), - Layer.succeed( - ManagedEndpointRuntime.CloudManagedEndpointRuntime, - ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: () => unused, - inspectConnector: Effect.succeed(connected), - readinessChanged: Stream.empty, - recoveryRequests: Stream.empty, - requestRecovery: () => unused, - tunnelConnected: Stream.empty, - withLinkStateLock: (effect) => effect, - }), - ), + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: () => Effect.sync(() => Option.some(config)), + }), + Layer.mock(ManagedEndpointRuntime.CloudManagedEndpointRuntime)({ + inspectConnector: Effect.succeed(connected), + readinessChanged: Stream.empty, + }), Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}"))), ), ), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => unused), - ), + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({}), ); return Effect.gen(function* () { const reachability = yield* ConnectReachability.ConnectReachability; yield* reachability.check; - expect(Option.getOrNull(yield* reachability.latest)).toMatchObject({ - hostname: "env.example.test", - }); + const result = Option.getOrThrow(yield* reachability.latest); + expect(result.status === "reachable" && connectReachableMcpUrl(result)).toBe( + "https://env.example.test/mcp", + ); // A relink stores a new tunnel before its connector reports in. - secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, new TextEncoder().encode("second")); + config = new TextEncoder().encode("second"); expect(yield* reachability.latest).toEqual(Option.none()); }).pipe(Effect.provide(ConnectReachability.layer.pipe(Layer.provide(dependencies)))); }); diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index d8c74225e820d..9800eed97f632 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -129,15 +129,11 @@ function useCopyMcpUrl() { }).copyToClipboard; } -/** - * This machine's T3 Connect MCP URL, from the host its running tunnel answered on. Shown only - * after a passing check, so it never hands out an address nothing serves. - */ export function PrimaryMcpUrlMenuItem() { - const { data } = usePrimaryCloudLinkState(); + const { data, isPending } = usePrimaryCloudLinkState(); const copyMcpUrl = useCopyMcpUrl(); const reachability = - data && data.managedTunnelActive !== false && data.linkRevoked !== true + data && !isPending && data.managedTunnelActive !== false && data.linkRevoked !== true ? data.reachability : undefined; const mcpUrl = reachability?.status === "reachable" ? connectReachableMcpUrl(reachability) : null; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index b3964dccbcecb..a4c88dcec8b7d 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -455,11 +455,6 @@ export function formatConnectReachable( ].join(" · "); } -/** - * The MCP URL outside agents use to reach this environment through T3 Connect. It comes from - * the host the running tunnel answered on, since a saved T3 Connect entry with the same name - * can belong to an older environment whose tunnel is gone. - */ export function connectReachableMcpUrl( result: Extract, ): string | null { From 48f5f6ae4cf34531b9428f1094c75bdecac76f8a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 12:43:01 +0000 Subject: [PATCH 05/13] fix(server): check the connector matches the saved tunnel Reject connector inspection for a different runtime configuration so recovery cannot cache the old host under its replacement. Extend the existing rotation test without adding fixtures. --- .../src/cloud/CloudLink.lifecycle.test.ts | 2 +- .../src/cloud/ConnectReachability.test.ts | 9 ++++--- apps/server/src/cloud/ConnectReachability.ts | 5 ++-- .../src/cloud/ManagedEndpointRuntime.test.ts | 24 ++++++++----------- .../src/cloud/ManagedEndpointRuntime.ts | 16 +++++++++---- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 7264afd6a452b..c3adc478c49c4 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -198,7 +198,7 @@ describe("releaseManagedTunnelOnShutdown", () => { recoveryRequests: Stream.empty, requestRecovery: () => Effect.void, tunnelConnected: Stream.empty, - inspectConnector: Effect.succeed(null), + inspectConnector: () => Effect.succeed(null), readinessChanged: Stream.empty, withLinkStateLock: (effect) => effect, }), diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index 5972ff4a3bf13..9fd4709aeace8 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -82,13 +82,14 @@ describe("classifyReachability", () => { describe("ConnectReachability", () => { it.effect("exposes the checked tunnel's MCP URL until its config changes", () => { - let config = new TextEncoder().encode("first"); + const tunnelConfig = { providerKind: "cloudflare_tunnel", connectorToken: "first" }; + let config = new TextEncoder().encode(JSON.stringify(tunnelConfig)); const dependencies = Layer.mergeAll( Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.sync(() => Option.some(config)), }), Layer.mock(ManagedEndpointRuntime.CloudManagedEndpointRuntime)({ - inspectConnector: Effect.succeed(connected), + inspectConnector: () => Effect.succeed(connected), readinessChanged: Stream.empty, }), Layer.succeed( @@ -109,7 +110,9 @@ describe("ConnectReachability", () => { ); // A relink stores a new tunnel before its connector reports in. - config = new TextEncoder().encode("second"); + config = new TextEncoder().encode( + JSON.stringify({ ...tunnelConfig, connectorToken: "second" }), + ); expect(yield* reachability.latest).toEqual(Option.none()); }).pipe(Effect.provide(ConnectReachability.layer.pipe(Layer.provide(dependencies)))); }); diff --git a/apps/server/src/cloud/ConnectReachability.ts b/apps/server/src/cloud/ConnectReachability.ts index b1a036bae2ccf..8765e26ca74e6 100644 --- a/apps/server/src/cloud/ConnectReachability.ts +++ b/apps/server/src/cloud/ConnectReachability.ts @@ -18,7 +18,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { CLOUD_ENDPOINT_RUNTIME_CONFIG } from "./config.ts"; +import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; // cloudflared finds the edge through the SRV record `_v2-origintunneld._tcp.argotunnel.com`, @@ -241,7 +241,8 @@ const make = Effect.gen(function* () { // Runs only inside the `ConnectReachability.check` span. const gatherFacts = Effect.fnUntraced(function* (tunnelConfig: string | null) { const managedTunnelConfigured = tunnelConfig !== null; - const connector = managedTunnelConfigured ? yield* runtime.inspectConnector : null; + const config = tunnelConfig ? Option.getOrNull(decodeRuntimeConfig(tunnelConfig)) : null; + const connector = config ? yield* runtime.inspectConnector(config) : null; const endpoint = connector?.hostname && connector.readyConnections !== 0 ? yield* probeEndpoint(connector.hostname) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 3b9d55ce3f6cd..671d1abe3085c 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -470,24 +470,20 @@ describe("CloudManagedEndpointRuntime", () => { ); const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token-1", - tunnelId: "tunnel-1", - tunnelName: "t3-code-env-1", - }); - yield* runtime.applyConfig({ + const config = { providerKind: "cloudflare_tunnel", connectorToken: "token-1", tunnelId: "tunnel-1", tunnelName: "t3-code-env-1", - }); - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token-2", - tunnelId: "tunnel-1", - tunnelName: "t3-code-env-1", - }); + } as const; + const replacement = { ...config, connectorToken: "token-2" }; + yield* runtime.applyConfig(config); + expect(yield* runtime.inspectConnector(config)).not.toBeNull(); + expect(yield* runtime.inspectConnector(replacement)).toBeNull(); + yield* runtime.applyConfig(config); + yield* runtime.applyConfig(replacement); + expect(yield* runtime.inspectConnector(replacement)).not.toBeNull(); + expect(yield* runtime.inspectConnector(config)).toBeNull(); const stopped = yield* runtime.applyConfig(null); expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 2bc742a170637..ea4724a764f90 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -57,8 +57,10 @@ export class CloudManagedEndpointRuntime extends Context.Service< readonly applyConfig: ( config: RelayManagedEndpointRuntimeConfig | null, ) => Effect.Effect; - /** `null` when no connector is running. */ - readonly inspectConnector: Effect.Effect; + /** `null` when no running connector matches this configuration. */ + readonly inspectConnector: ( + config: RelayManagedEndpointRuntimeConfig, + ) => Effect.Effect; /** Emits when a connector registers a connection or its readiness changes. */ readonly readinessChanged: Stream.Stream; readonly recoveryRequests: Stream.Stream; @@ -759,9 +761,15 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); - const inspectConnector = Effect.gen(function* () { + const inspectConnector = Effect.fn("CloudManagedEndpointRuntime.inspectConnector")(function* ( + expectedConfig: RelayManagedEndpointRuntimeConfig, + ) { const active = yield* Ref.get(activeRef); - if (!active || !(yield* active.child.isRunning.pipe(Effect.orElseSucceed(() => false)))) { + if ( + !active || + active.configKey !== runtimeConfigKey(expectedConfig) || + !(yield* active.child.isRunning.pipe(Effect.orElseSucceed(() => false))) + ) { return null; } const protocol = yield* Ref.get(active.protocol); From 928de7f16430d04876b318c6d4eb6b5f69407a38 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 12:59:24 +0000 Subject: [PATCH 06/13] fix(web): hide the MCP URL while link settings change Share mutation activity between the tunnel controls and primary-machine menu. Preserve it while Settings is closed and clear it when reconciliation finishes or fails. --- apps/web/src/cloud/primaryCloudLinkState.ts | 7 ++++- .../settings/SavedEnvironmentRows.tsx | 29 ++++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 9672f2a00366e..7c8f3c5b3b903 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -39,6 +39,10 @@ const primaryCloudLinkStateAtom = Atom.family((key: string) => { ); }); +export const primaryCloudLinkMutationAtom = Atom.family((_environmentId: EnvironmentId | null) => + Atom.make(false).pipe(Atom.keepAlive), +); + const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( AsyncResult.success(null), ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); @@ -80,6 +84,7 @@ export function usePrimaryCloudLinkState() { ? primaryCloudLinkStateAtom(targetKey(target)) : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); + const isMutating = useAtomValue(primaryCloudLinkMutationAtom(primary?.environmentId ?? null)); const refresh = () => { refreshPrimaryCloudLinkState(target); }; @@ -92,7 +97,7 @@ export function usePrimaryCloudLinkState() { return { data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)), error, - isPending: result.waiting, + isPending: result.waiting || isMutating, refresh, target, }; diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 9800eed97f632..a0f1330a14e8c 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -5,7 +5,7 @@ import { EllipsisIcon, RouteIcon, } from "lucide-react"; -import { useAtomValue } from "@effect/atom-react"; +import { useAtom, useAtomValue } from "@effect/atom-react"; import { useEffect, useEffectEvent, useState } from "react"; import { AuthRelayWriteScope, @@ -49,8 +49,11 @@ import { supportsServerUpdateThreadContinuation, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; -import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; -import { useCloudLinkController } from "~/cloud/useCloudLinkController"; +import { + primaryCloudLinkMutationAtom, + usePrimaryCloudLinkState, +} from "~/cloud/primaryCloudLinkState"; +import { useCloudLinkController, type CloudLinkDesiredState } from "~/cloud/useCloudLinkController"; import { environmentCatalog } from "~/connection/catalog"; import { readEnvironmentScope } from "~/state/session"; import { @@ -493,17 +496,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b publishAgentActivity, holdWebhooksWhileOffline, operationError, - reconcileCloudState, + reconcileCloudState: reconcileLink, } = useCloudLinkController(); - const [isUpdating, setIsUpdating] = useState(false); - const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); + const [isBusy, setIsBusy] = useAtom(primaryCloudLinkMutationAtom(primaryEnvironmentId)); + const reconcileCloudState = async (desired: CloudLinkDesiredState) => { + setIsBusy(true); + try { + return await reconcileLink(desired); + } finally { + setIsBusy(false); + } + }; const disabledReason = !isSignedIn ? "Sign in to T3 Connect to manage this environment." : !canManageRelay ? "Your session does not have permission to manage T3 Connect access." : null; - const isBusy = isUpdating || isUpdatingPreference; const refreshLinkState = useEffectEvent(primaryCloudLinkState.refresh); // The server rechecks reachability when the tunnel's 30 s readiness poll changes. // Poll while mounted, so a failed read does not stop later ones. @@ -537,7 +546,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) ) return; - setIsUpdating(true); const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); if (ok) { toastManager.add({ @@ -554,7 +562,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b : "This environment is no longer available through T3 Connect.", }); } - setIsUpdating(false); }; const updatePublishAgentActivity = async (enabled: boolean) => { @@ -563,7 +570,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) ) return; - setIsUpdatingPreference(true); const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled }); if (ok) { toastManager.add({ @@ -574,11 +580,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b : "This environment will stop publishing agent activity.", }); } - setIsUpdatingPreference(false); }; const updateHoldWebhooks = async (enabled: boolean) => { - setIsUpdatingPreference(true); const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: publishAgentActivity, @@ -593,7 +597,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b : "Requests to an offline environment now fail. Anything already held is still delivered.", }); } - setIsUpdatingPreference(false); }; return ( From 4fff01ac9605ffc3cd950d209bbe984cd70bbf2a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 13:07:05 +0000 Subject: [PATCH 07/13] fix(web): keep cloud link cleanup compatible with React Compiler --- apps/web/src/components/settings/SavedEnvironmentRows.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index a0f1330a14e8c..82864f6868338 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -499,13 +499,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b reconcileCloudState: reconcileLink, } = useCloudLinkController(); const [isBusy, setIsBusy] = useAtom(primaryCloudLinkMutationAtom(primaryEnvironmentId)); - const reconcileCloudState = async (desired: CloudLinkDesiredState) => { + const reconcileCloudState = (desired: CloudLinkDesiredState) => { setIsBusy(true); - try { - return await reconcileLink(desired); - } finally { - setIsBusy(false); - } + return reconcileLink(desired).finally(() => setIsBusy(false)); }; const disabledReason = !isSignedIn From 7b15585f8037739b7ccbd5b5320192d4a74c731b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 13:27:35 +0000 Subject: [PATCH 08/13] fix(connect): discard replaced connector facts and respect relay state --- .../src/cloud/ManagedEndpointRuntime.test.ts | 42 ++++++++++++++++++- .../src/cloud/ManagedEndpointRuntime.ts | 6 +++ .../settings/ConnectionsSettings.tsx | 2 +- .../settings/SavedEnvironmentRows.tsx | 12 +++--- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 671d1abe3085c..07665ee1c6f79 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -12,7 +12,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; @@ -288,6 +288,46 @@ describe("CloudManagedEndpointRuntime", () => { return { spawner, spawned, killed, exits, started, emit, observe }; }); + it.effect("discards metrics from a connector replaced with the same configuration", () => + Effect.gen(function* () { + const readingConfig = yield* Deferred.make(); + const releaseConfig = yield* Deferred.make(); + const fixture = yield* makeTransportSpawner(); + const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + const isConfig = request.url.endsWith("/config"); + if (isConfig) { + yield* Deferred.succeed(readingConfig, undefined); + yield* Deferred.await(releaseConfig); + } + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify( + isConfig + ? { config: { ingress: [{ hostname: "old.example.test" }] } } + : { readyConnections: 4 }, + ), + ), + ); + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + fixture.spawner, + layerRelayClientAvailable, + httpClient, + ); + yield* runtime.applyConfig(tunnelConfig); + yield* fixture.observe(runtime, 0, "Starting metrics server on 127.0.0.1:12345/metrics\n"); + const inspection = yield* runtime.inspectConnector(tunnelConfig).pipe(Effect.forkChild); + yield* Deferred.await(readingConfig); + yield* runtime.applyConfig(null); + yield* runtime.applyConfig(tunnelConfig); + yield* Deferred.succeed(releaseConfig, undefined); + expect(yield* Fiber.join(inspection)).toBeNull(); + }), + ); + it.effect("preserves an explicit transport choice despite repeated QUIC stalls", () => Effect.gen(function* () { const fixture = yield* makeTransportSpawner({ transport: "quic" }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index ea4724a764f90..77c8b6a0b5ade 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -784,6 +784,12 @@ export const make = Effect.gen(function* () { ], { concurrency: 2 }, ); + if ( + !(yield* active.child.isRunning.pipe(Effect.orElseSucceed(() => false))) || + (yield* Ref.get(activeRef)) !== active + ) { + return null; + } return { readyConnections: ready?.readyConnections ?? null, hostname: config?.config.ingress?.find((rule) => rule.hostname)?.hostname ?? null, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index dd1328c7c2f3d..4732c5fa50400 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1070,7 +1070,7 @@ export function ConnectionsSettings() { environmentId={primaryEnvironmentId} serverConfig={primaryServerConfig} /> - + {hasCloudPublicConfig() ? : null} ) : null diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 82864f6868338..913c3a0001a88 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -49,10 +49,7 @@ import { supportsServerUpdateThreadContinuation, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; -import { - primaryCloudLinkMutationAtom, - usePrimaryCloudLinkState, -} from "~/cloud/primaryCloudLinkState"; +import { primaryCloudLinkMutationAtom } from "~/cloud/primaryCloudLinkState"; import { useCloudLinkController, type CloudLinkDesiredState } from "~/cloud/useCloudLinkController"; import { environmentCatalog } from "~/connection/catalog"; import { readEnvironmentScope } from "~/state/session"; @@ -133,10 +130,13 @@ function useCopyMcpUrl() { } export function PrimaryMcpUrlMenuItem() { - const { data, isPending } = usePrimaryCloudLinkState(); + const { + linkState: { data, isPending }, + managedTunnelActive, + } = useCloudLinkController(); const copyMcpUrl = useCopyMcpUrl(); const reachability = - data && !isPending && data.managedTunnelActive !== false && data.linkRevoked !== true + data && !isPending && managedTunnelActive && data.linkRevoked !== true ? data.reachability : undefined; const mcpUrl = reachability?.status === "reachable" ? connectReachableMcpUrl(reachability) : null; From 9251ff1ad9e1f1741b9f9dafab0684c45685cbcf Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:59:53 +0000 Subject: [PATCH 09/13] fix(server): guest sharing reads the running connector after the merge Main's guest sharing calls inspectConnector without a tunnel config. Keep the config optional so it still reads whichever connector is running, while T3 Connect reachability checks the saved one. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/ManagedEndpointRuntime.ts | 8 ++++---- apps/server/src/guest/GuestShareService.test.ts | 11 ++++++----- apps/server/src/guest/GuestShareService.ts | 6 +++--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 4d2f07bf66ee2..2ec3d8ff037ec 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -57,9 +57,9 @@ export class CloudManagedEndpointRuntime extends Context.Service< readonly applyConfig: ( config: RelayManagedEndpointRuntimeConfig | null, ) => Effect.Effect; - /** `null` when no running connector matches this configuration. */ + /** `null` when no connector is running, or, given a configuration, none matches it. */ readonly inspectConnector: ( - config: RelayManagedEndpointRuntimeConfig, + config?: RelayManagedEndpointRuntimeConfig, ) => Effect.Effect; /** Emits when a connector registers a connection or its readiness changes. */ readonly readinessChanged: Stream.Stream; @@ -733,12 +733,12 @@ export const make = Effect.gen(function* () { ); const inspectConnector = Effect.fn("CloudManagedEndpointRuntime.inspectConnector")(function* ( - expectedConfig: RelayManagedEndpointRuntimeConfig, + expectedConfig?: RelayManagedEndpointRuntimeConfig, ) { const active = yield* Ref.get(activeRef); if ( !active || - active.configKey !== runtimeConfigKey(expectedConfig) || + (expectedConfig !== undefined && active.configKey !== runtimeConfigKey(expectedConfig)) || !(yield* active.child.isRunning.pipe(Effect.orElseSucceed(() => false))) ) { return null; diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 97cbae14e9e4e..e17e93b148286 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -72,11 +72,12 @@ const layerOrchestration = Layer.mergeAll( // The Connect hostname the fake tunnel reports. Tests set it to null to simulate an unlink. const hostname = Ref.makeUnsafe("env.t3.test"); const layerConnect = Layer.mock(CloudManagedEndpointRuntime)({ - inspectConnector: Effect.map(Ref.get(hostname), (value) => ({ - readyConnections: 1, - hostname: value, - protocol: "http2", - })), + inspectConnector: () => + Effect.map(Ref.get(hostname), (value) => ({ + readyConnections: 1, + hostname: value, + protocol: "http2", + })), }); const layerProviders = Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 99d1ff3b56b83..15d76f1bb1d2d 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -259,9 +259,9 @@ const make = Effect.gen(function* () { return decodeSessionClaims(base64UrlDecodeUtf8(parts.payload)); }); - const connectHostname = managedEndpoint.inspectConnector.pipe( - Effect.map((facts) => facts?.hostname ?? null), - ); + const connectHostname = managedEndpoint + .inspectConnector() + .pipe(Effect.map((facts) => facts?.hostname ?? null)); // A beta: while it is off, no link opens and every guest request is refused. const sharingEnabled = serverSettings.getSettings.pipe( Effect.map((settings) => settings.guestSharingEnabled), From c4de5cedf1f639b087dfa90f66ae8ab16e43b0a0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:48:04 +0000 Subject: [PATCH 10/13] fix(connect): recheck tunnel configuration before reporting its URL --- apps/server/src/cli/connect.test.ts | 36 +++++++++++- apps/server/src/cli/connect.ts | 5 +- .../src/cloud/ConnectReachability.test.ts | 58 +++++++++++++------ apps/server/src/cloud/ConnectReachability.ts | 3 +- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 6693d214fabac..40fecf67d590a 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -3,7 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { acquireRelayClientForLink } from "./connect.ts"; +import { acquireRelayClientForLink, formatCloudStatus } from "./connect.ts"; const managedExecutable = { status: "available", @@ -40,3 +40,37 @@ it.effect("does not install the relay client when the user declines the managed assert.equal(installCalls, 0); }), ); + +const linkedStatus = { + desired: true, + authenticated: true, + linked: true, + revoked: false, + cloudUserId: "user", + relayUrl: "https://relay.example.test", + publishAgentActivity: true, + relayClient: managedExecutable, + linkMode: "managed", + reachability: { + status: "reachable", + checkedAt: "2026-10-26T00:00:00.000Z", + readyConnections: 4, + protocol: "quic", + hostname: "tunnel.example.test", + }, +} as const; + +it("prints the checked tunnel's MCP URL in connect status", () => { + const output = formatCloudStatus(linkedStatus); + assert.include(output, "Other devices can reach this machine"); + assert.include(output, "MCP URL for outside agents: https://tunnel.example.test/mcp"); + assert.deepEqual(JSON.parse(formatCloudStatus(linkedStatus, { json: true })), linkedStatus); +}); + +it("omits MCP URLs when no tunnel hostname was checked", () => { + const { hostname: _hostname, ...reachability } = linkedStatus.reachability; + assert.notInclude(formatCloudStatus({ ...linkedStatus, reachability }), "MCP URL"); + assert.notInclude(formatCloudStatus({ ...linkedStatus, reachability: null }), "MCP URL"); + assert.notInclude(formatCloudStatus({ ...linkedStatus, desired: false }), "MCP URL"); + assert.notInclude(formatCloudStatus({ ...linkedStatus, linkMode: "publish_only" }), "MCP URL"); +}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 260b1974ae85a..c02e7e3cf9323 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -179,7 +179,10 @@ function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): Rea } } -function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string { +export function formatCloudStatus( + status: CloudCliStatus, + options?: { readonly json?: boolean }, +): string { if (options?.json) { return JSON.stringify(status, null, 2); } diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index 9fd4709aeace8..1361a59635c74 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -11,15 +11,18 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ConnectReachability from "./ConnectReachability.ts"; +import { + ConnectReachability, + layer, + classifyReachability, + type ReachabilityFacts, +} from "./ConnectReachability.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const checkedAt = "2026-10-19T00:00:00.000Z"; const connected = { readyConnections: 4, hostname: "env.example.test", protocol: "quic" }; const notConnected = { readyConnections: 0, hostname: null, protocol: null }; -const facts = ( - overrides: Partial, -): ConnectReachability.ReachabilityFacts => ({ +const facts = (overrides: Partial): ReachabilityFacts => ({ managedTunnelConfigured: true, connector: connected, endpoint: null, @@ -34,21 +37,21 @@ describe("classifyReachability", () => { [{ kind: "status", status: 530 }, "no_connector"], [{ kind: "network_error" }, "timeout"], ] as const)("maps endpoint result %o to %s", (endpoint, cause) => { - expect( - ConnectReachability.classifyReachability(facts({ endpoint, edgeReachable: true }), checkedAt), - ).toMatchObject({ status: "unreachable", cause }); + expect(classifyReachability(facts({ endpoint, edgeReachable: true }), checkedAt)).toMatchObject( + { status: "unreachable", cause }, + ); }); it("keeps the endpoint cause when the TCP edge test cannot explain it", () => { // QUIC (UDP) can connect while TCP 7844 is blocked. expect( - ConnectReachability.classifyReachability( + classifyReachability( facts({ endpoint: { kind: "status", status: 502 }, edgeReachable: false }), checkedAt, ), ).toMatchObject({ cause: "origin_unreachable" }); expect( - ConnectReachability.classifyReachability( + classifyReachability( facts({ connector: { readyConnections: null, hostname: "env.example.test", protocol: null }, endpoint: { kind: "status", status: 502 }, @@ -59,7 +62,7 @@ describe("classifyReachability", () => { ).toMatchObject({ cause: "origin_unreachable" }); // Failed DNS also fails the edge lookups. expect( - ConnectReachability.classifyReachability( + classifyReachability( facts({ connector: { readyConnections: null, hostname: "env.example.test", protocol: null }, endpoint: { kind: "dns_failed" }, @@ -72,10 +75,7 @@ describe("classifyReachability", () => { it("waits on a connector that has not connected while the edge answers", () => { expect( - ConnectReachability.classifyReachability( - facts({ connector: notConnected, edgeReachable: true }), - checkedAt, - ), + classifyReachability(facts({ connector: notConnected, edgeReachable: true }), checkedAt), ).toMatchObject({ cause: "connector_not_ready" }); }); }); @@ -84,25 +84,40 @@ describe("ConnectReachability", () => { it.effect("exposes the checked tunnel's MCP URL until its config changes", () => { const tunnelConfig = { providerKind: "cloudflare_tunnel", connectorToken: "first" }; let config = new TextEncoder().encode(JSON.stringify(tunnelConfig)); + let relinkDuringProbe = false; const dependencies = Layer.mergeAll( Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.sync(() => Option.some(config)), }), Layer.mock(ManagedEndpointRuntime.CloudManagedEndpointRuntime)({ - inspectConnector: () => Effect.succeed(connected), + inspectConnector: () => + Effect.succeed({ + ...connected, + hostname: new TextDecoder().decode(config).includes("second") + ? "replacement.example.test" + : connected.hostname, + }), readinessChanged: Stream.empty, }), Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}"))), + Effect.sync(() => { + if (relinkDuringProbe) { + relinkDuringProbe = false; + config = new TextEncoder().encode( + JSON.stringify({ ...tunnelConfig, connectorToken: "second" }), + ); + } + return HttpClientResponse.fromWeb(request, new Response("{}")); + }), ), ), Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({}), ); return Effect.gen(function* () { - const reachability = yield* ConnectReachability.ConnectReachability; + const reachability = yield* ConnectReachability; yield* reachability.check; const result = Option.getOrThrow(yield* reachability.latest); expect(result.status === "reachable" && connectReachableMcpUrl(result)).toBe( @@ -114,7 +129,14 @@ describe("ConnectReachability", () => { JSON.stringify({ ...tunnelConfig, connectorToken: "second" }), ); expect(yield* reachability.latest).toEqual(Option.none()); - }).pipe(Effect.provide(ConnectReachability.layer.pipe(Layer.provide(dependencies)))); + + config = new TextEncoder().encode(JSON.stringify(tunnelConfig)); + relinkDuringProbe = true; + const refreshed = yield* reachability.check; + expect(refreshed.status === "reachable" && connectReachableMcpUrl(refreshed)).toBe( + "https://replacement.example.test/mcp", + ); + }).pipe(Effect.provide(layer.pipe(Layer.provide(dependencies)))); }); }); diff --git a/apps/server/src/cloud/ConnectReachability.ts b/apps/server/src/cloud/ConnectReachability.ts index 8765e26ca74e6..e87868a495cb9 100644 --- a/apps/server/src/cloud/ConnectReachability.ts +++ b/apps/server/src/cloud/ConnectReachability.ts @@ -269,10 +269,11 @@ const make = Effect.gen(function* () { return { ...facts, edgeReachable: edgeResults.some(Boolean), exitNode }; }); - const check = Effect.gen(function* () { + const check: Effect.Effect = Effect.gen(function* () { const order = yield* Ref.updateAndGet(checkCounter, (count) => count + 1); const tunnelConfig = yield* readTunnelConfig; const facts = yield* gatherFacts(tunnelConfig); + if ((yield* readTunnelConfig) !== tunnelConfig) return yield* check; const result = classifyReachability(facts, DateTime.formatIso(yield* DateTime.now)); const previous = yield* Ref.modify(latestRef, (current) => Option.isSome(current) && current.value.order > order From 8a624e1b7bfb30a50a597b5483e8005dcb8bfdf3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 08:06:34 +0000 Subject: [PATCH 11/13] fix(connect): omit revoked tunnel MCP URLs from status --- apps/server/src/cli/connect.test.ts | 1 + apps/server/src/cli/connect.ts | 2 +- docs/user/outside-agents.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 40fecf67d590a..6d76ec9eb805f 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -72,5 +72,6 @@ it("omits MCP URLs when no tunnel hostname was checked", () => { assert.notInclude(formatCloudStatus({ ...linkedStatus, reachability }), "MCP URL"); assert.notInclude(formatCloudStatus({ ...linkedStatus, reachability: null }), "MCP URL"); assert.notInclude(formatCloudStatus({ ...linkedStatus, desired: false }), "MCP URL"); + assert.notInclude(formatCloudStatus({ ...linkedStatus, revoked: true }), "MCP URL"); assert.notInclude(formatCloudStatus({ ...linkedStatus, linkMode: "publish_only" }), "MCP URL"); }); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index c02e7e3cf9323..a03dfbfd2a7e9 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -204,7 +204,7 @@ export function formatCloudStatus( ? "Start T3 to provision the environment link and launch its managed tunnel." : undefined; const mcpUrl = - status.reachability?.status === "reachable" + !status.revoked && status.reachability?.status === "reachable" ? connectReachableMcpUrl(status.reachability) : null; diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index bb4328e051292..db83ebfa970f0 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -20,7 +20,7 @@ The copied URL uses the route this device is connected over, so an agent on the same device can reach it. For the machine you are on, open the menu next to its name at the top of -**Settings → Connections** instead. Once T3 Connect has checked that other +**Settings → Sharing & Access** instead. Once T3 Connect has checked that other devices can reach it, **Copy MCP URL** there gives the T3 Connect address its tunnel is serving. On a server set up with `t3 connect`, `t3 connect status` prints the same URL. A saved environment with the same name is a different From 8c26df4564a4a817b2ff2fb7320975c2f2509ecd Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:37:27 +0000 Subject: [PATCH 12/13] style: format Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/settings/SavedEnvironmentRows.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 50fdd133df9bf..99c04d950ea67 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -130,7 +130,11 @@ function useCopyMcpUrl() { } /** Copies the MCP URL this environment's own T3 Connect tunnel answered on. */ -export function ConnectMcpUrlMenuItem({ environmentId }: { readonly environmentId: EnvironmentId }) { +export function ConnectMcpUrlMenuItem({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { const { linkState, managedTunnelActive } = useCloudLinkController(environmentId); const copyMcpUrl = useCopyMcpUrl(); const reachability = managedTunnelActive ? linkState.data?.reachability : undefined; From 43caf534d70519fa135195adce34bed9236a62b0 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:46:28 +0000 Subject: [PATCH 13/13] fix(web): read this environment's tunnel host from cloud link state Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/settings/ConnectionsSettings.tsx | 4 +--- apps/web/src/components/settings/SavedEnvironmentRows.tsx | 7 +++++-- docs/user/outside-agents.md | 7 +++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 30701da0fbe4b..e75499e0a5efc 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1175,9 +1175,7 @@ function ConnectionsSettingsContent({ environmentId={accessEnvironmentId} serverConfig={accessServerConfig} /> - {canReadRelay && hasCloudPublicConfig() ? ( - - ) : null} + ) : null diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 99c04d950ea67..ff690e1cde9a2 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -50,6 +50,7 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; +import { useCloudLinkState } from "~/cloud/cloudLinkState"; import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; import { useT3ConnectAccountPage } from "../clerk/T3ConnectAccountPages"; import { TunnelLimitAlert, TunnelUsageMeter } from "../cloud/TunnelUsage"; @@ -135,9 +136,11 @@ export function ConnectMcpUrlMenuItem({ }: { readonly environmentId: EnvironmentId; }) { - const { linkState, managedTunnelActive } = useCloudLinkController(environmentId); + const { data } = useCloudLinkState(environmentId); const copyMcpUrl = useCopyMcpUrl(); - const reachability = managedTunnelActive ? linkState.data?.reachability : undefined; + // A stopped tunnel triggers no recheck, so the last result can outlive it. + const reachability = + data?.managedTunnelActive === true && data.linkRevoked !== true ? data.reachability : undefined; if (reachability?.status !== "reachable" || !reachability.hostname) return null; const mcpUrl = `https://${reachability.hostname}/mcp`; return copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL; diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index dbe5f12b9459c..8b61a79575f6c 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -19,10 +19,9 @@ https:///mcp The copied URL uses the route this device is connected over, so an agent on the same device can reach it. -For the machine you are on, open the menu next to its name in **Settings → -Sharing & access**. Once T3 Connect reports the tunnel connected, **Copy MCP -URL** there gives the T3 Connect address. A saved environment with the same name -is a different environment. +For the environment at the top of **Settings → Sharing & access**, open the +menu next to its name. Once T3 Connect reports its tunnel connected, **Copy MCP +URL** there gives the address that tunnel serves. - **An agent on your own computers** can use `localhost` on the host, T3 Connect, or any `https` address, such as