Skip to content

Commit 895fb16

Browse files
authored
Merge pull request #187 from jayleaton/fix/gateway-bridge-health-20261104
fix(mcp): the gateway gets a durable token and agents see every device's environments
2 parents c73dd6d + e902591 commit 895fb16

13 files changed

Lines changed: 303 additions & 43 deletions

File tree

‎apps/desktop/src/ipc/DesktopIpcHandlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
getAppBranding,
3838
getClientDeviceName,
3939
getMcpGatewayBridgeToken,
40+
ensureMcpGatewayBridgeToken,
4041
getMcpGatewayLaunchConfig,
4142
getLocalEnvironmentBootstraps,
4243
getLocalEnvironmentBearerToken,
@@ -97,6 +98,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
9798
yield* ipc.handleSync(getAppBranding);
9899
yield* ipc.handleSync(getClientDeviceName);
99100
yield* ipc.handleSync(getMcpGatewayBridgeToken);
101+
yield* ipc.handleSync(ensureMcpGatewayBridgeToken);
100102
yield* ipc.handleSync(getMcpGatewayLaunchConfig);
101103
yield* ipc.handle(McpGatewayIpc.configureManagedMcpGateway);
102104
yield* ipc.handleSync(getRelayTelemetryEnabled);

‎apps/desktop/src/ipc/channels.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall";
3333
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
3434
export const GET_MCP_GATEWAY_LAUNCH_CONFIG_CHANNEL = "desktop:get-mcp-gateway-launch-config";
3535
export const GET_MCP_GATEWAY_BRIDGE_TOKEN_CHANNEL = "desktop:get-mcp-gateway-bridge-token";
36+
export const ENSURE_MCP_GATEWAY_BRIDGE_TOKEN_CHANNEL = "desktop:ensure-mcp-gateway-bridge-token";
3637
export const GET_CLIENT_DEVICE_NAME_CHANNEL = "desktop:get-client-device-name";
3738
export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled";
3839
export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";

‎apps/desktop/src/ipc/methods/window.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermiss
4343
import * as IpcChannels from "../channels.ts";
4444
import * as DesktopIpc from "../DesktopIpc.ts";
4545
import { resolveMcpGatewayLaunchConfig } from "../../mcpGatewayLaunchConfig.ts";
46-
import { readMcpGatewayBridgeTokenFromProcess } from "../../mcpGatewayCredential.ts";
46+
import {
47+
ensureMcpGatewayBridgeTokenFromProcess,
48+
readMcpGatewayBridgeTokenFromProcess,
49+
} from "../../mcpGatewayCredential.ts";
4750
import {
4851
extractDistroFromUncPath,
4952
resolveWslPickFolderDefaultPath,
@@ -105,6 +108,15 @@ export const getMcpGatewayBridgeToken = DesktopIpc.makeSyncIpcMethod({
105108
}),
106109
});
107110

111+
export const ensureMcpGatewayBridgeToken = DesktopIpc.makeSyncIpcMethod({
112+
channel: IpcChannels.ENSURE_MCP_GATEWAY_BRIDGE_TOKEN_CHANNEL,
113+
result: Schema.NullOr(Schema.String),
114+
handler: Effect.fn("desktop.ipc.window.ensureMcpGatewayBridgeToken")(function* () {
115+
const environment = yield* DesktopEnvironment.DesktopEnvironment;
116+
return ensureMcpGatewayBridgeTokenFromProcess(environment.homeDirectory);
117+
}),
118+
});
119+
108120
/** The machine's hostname, so agents can tell this desktop apart from the user's other devices. */
109121
export const getClientDeviceName = DesktopIpc.makeSyncIpcMethod({
110122
channel: IpcChannels.GET_CLIENT_DEVICE_NAME_CHANNEL,

‎apps/desktop/src/mcpGatewayCredential.test.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { assert, describe, it } from "@effect/vitest";
22

3-
import { readMcpGatewayBridgeToken } from "./mcpGatewayCredential.ts";
3+
import { ensureMcpGatewayBridgeToken, readMcpGatewayBridgeToken } from "./mcpGatewayCredential.ts";
44

55
const secureFile = {
66
isFile: () => true,
@@ -84,3 +84,43 @@ describe("readMcpGatewayBridgeToken", () => {
8484
}
8585
});
8686
});
87+
88+
describe("ensureMcpGatewayBridgeToken", () => {
89+
const store = (files: Map<string, { contents: string; mode: number }>) => ({
90+
env: {},
91+
homeDirectory: "/home/tester",
92+
userId: 1000,
93+
statFile: (path: string) => {
94+
const file = files.get(path);
95+
if (file === undefined) throw new Error("ENOENT");
96+
return { ...secureFile, mode: file.mode };
97+
},
98+
readFileString: (path: string) => files.get(path)!.contents,
99+
makeDirectory: () => undefined,
100+
createFileExclusive: (path: string, contents: string) => {
101+
if (files.has(path)) throw new Error("EEXIST");
102+
files.set(path, { contents, mode: 0o100600 });
103+
},
104+
newToken: () => "generated-token-0123456789abcdef",
105+
});
106+
107+
it("creates the protected env file once, so the token survives a restart", () => {
108+
const files = new Map<string, { contents: string; mode: number }>();
109+
assert.strictEqual(
110+
ensureMcpGatewayBridgeToken(store(files)),
111+
"generated-token-0123456789abcdef",
112+
);
113+
assert.deepStrictEqual([...files.keys()], ["/home/tester/.config/t3code/mcp-gateway.env"]);
114+
// A new app session reads the same token without generating another.
115+
assert.strictEqual(readMcpGatewayBridgeToken(store(files)), "generated-token-0123456789abcdef");
116+
});
117+
118+
it("leaves an existing but unusable env file for its owner to fix", () => {
119+
const path = "/home/tester/.config/t3code/mcp-gateway.env";
120+
const files = new Map([
121+
[path, { contents: "T3_MCP_BRIDGE_TOKEN=shared-token-123456\n", mode: 0o100644 }],
122+
]);
123+
assert.isNull(ensureMcpGatewayBridgeToken(store(files)));
124+
assert.strictEqual(files.get(path)?.contents, "T3_MCP_BRIDGE_TOKEN=shared-token-123456\n");
125+
});
126+
});

‎apps/desktop/src/mcpGatewayCredential.ts‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// @effect-diagnostics nodeBuiltinImport:off - the synchronous preload bootstrap needs a synchronous, fail-closed credential read.
2+
import * as NodeCrypto from "node:crypto";
23
import * as NodeFS from "node:fs";
34
import * as NodePath from "node:path";
45

@@ -43,16 +44,14 @@ function parseTokenEnvironmentFile(contents: string): string | null {
4344
return null;
4445
}
4546

47+
const credentialFile = (homeDirectory: string) =>
48+
NodePath.join(homeDirectory, ".config", "t3code", "mcp-gateway.env");
49+
4650
export function readMcpGatewayBridgeToken(source: McpGatewayCredentialSource): string | null {
4751
const environmentToken = validateToken(source.env.T3_MCP_BRIDGE_TOKEN);
4852
if (environmentToken !== null) return environmentToken;
4953

50-
const credentialPath = NodePath.join(
51-
source.homeDirectory,
52-
".config",
53-
"t3code",
54-
"mcp-gateway.env",
55-
);
54+
const credentialPath = credentialFile(source.homeDirectory);
5655
try {
5756
const file = source.statFile(credentialPath);
5857
if (!file.isFile() || (source.userId !== null && file.uid !== source.userId)) return null;
@@ -63,12 +62,49 @@ export function readMcpGatewayBridgeToken(source: McpGatewayCredentialSource): s
6362
}
6463
}
6564

65+
export interface McpGatewayCredentialStore extends McpGatewayCredentialSource {
66+
readonly makeDirectory: (path: string) => void;
67+
/** Creates the file only if it does not exist yet. */
68+
readonly createFileExclusive: (path: string, contents: string) => void;
69+
readonly newToken: () => string;
70+
}
71+
72+
/**
73+
* The token, creating the protected env file when there is none, so an enabled gateway keeps
74+
* the same token across restarts. A file that exists but is unusable is left for its owner to fix.
75+
*/
76+
export function ensureMcpGatewayBridgeToken(store: McpGatewayCredentialStore): string | null {
77+
const existing = readMcpGatewayBridgeToken(store);
78+
if (existing !== null) return existing;
79+
const credentialPath = credentialFile(store.homeDirectory);
80+
try {
81+
store.makeDirectory(NodePath.dirname(credentialPath));
82+
store.createFileExclusive(
83+
credentialPath,
84+
`${TOKEN_ENVIRONMENT_VARIABLE}=${store.newToken()}\n`,
85+
);
86+
} catch {
87+
// Another window created it first, or it cannot be written; the read below decides.
88+
}
89+
return readMcpGatewayBridgeToken(store);
90+
}
91+
92+
const processCredentialSource = (homeDirectory: string): McpGatewayCredentialStore => ({
93+
env: process.env,
94+
homeDirectory,
95+
userId: typeof process.getuid === "function" ? process.getuid() : null,
96+
statFile: NodeFS.statSync,
97+
readFileString: (path) => NodeFS.readFileSync(path, "utf8"),
98+
makeDirectory: (path) => NodeFS.mkdirSync(path, { recursive: true, mode: 0o700 }),
99+
createFileExclusive: (path, contents) =>
100+
NodeFS.writeFileSync(path, contents, { flag: "wx", mode: 0o600 }),
101+
newToken: () => NodeCrypto.randomBytes(32).toString("hex"),
102+
});
103+
66104
export function readMcpGatewayBridgeTokenFromProcess(homeDirectory: string): string | null {
67-
return readMcpGatewayBridgeToken({
68-
env: process.env,
69-
homeDirectory,
70-
userId: typeof process.getuid === "function" ? process.getuid() : null,
71-
statFile: NodeFS.statSync,
72-
readFileString: (path) => NodeFS.readFileSync(path, "utf8"),
73-
});
105+
return readMcpGatewayBridgeToken(processCredentialSource(homeDirectory));
106+
}
107+
108+
export function ensureMcpGatewayBridgeTokenFromProcess(homeDirectory: string): string | null {
109+
return ensureMcpGatewayBridgeToken(processCredentialSource(homeDirectory));
74110
}

‎apps/desktop/src/preload.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
9191
const result = ipcRenderer.sendSync(IpcChannels.GET_MCP_GATEWAY_BRIDGE_TOKEN_CHANNEL);
9292
return typeof result === "string" ? result : null;
9393
},
94+
ensureMcpGatewayBridgeToken: () => {
95+
const result = ipcRenderer.sendSync(IpcChannels.ENSURE_MCP_GATEWAY_BRIDGE_TOKEN_CHANNEL);
96+
return typeof result === "string" ? result : null;
97+
},
9498
getRelayTelemetryEnabled: () =>
9599
ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true,
96100
getPathForFile: (file: File) => webUtils.getPathForFile(file),

‎apps/server/src/mcp/McpGatewayBroker.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as Exit from "effect/Exit";
1212
import * as Fiber from "effect/Fiber";
1313
import * as Schema from "effect/Schema";
1414
import * as Stream from "effect/Stream";
15+
import * as TestClock from "effect/testing/TestClock";
1516
import { make } from "./McpGatewayBroker.ts";
1617

1718
type Broker = Effect.Success<typeof make>;
@@ -61,6 +62,45 @@ it.effect("runs calls on the newest app that reaches every environment in them",
6162
).pipe(Effect.provide(NodeServices.layer)),
6263
);
6364

65+
it.effect(
66+
"lists every connected app's environments and routes around an app that stopped answering",
67+
() =>
68+
Effect.scoped(
69+
Effect.gen(function* () {
70+
const broker = yield* make;
71+
const summary = (environmentId: string, connectionState: string) => ({
72+
environmentId,
73+
label: environmentId,
74+
connectionState,
75+
targetKind: "remote",
76+
});
77+
yield* serve(broker, "studio", { studio: ["read"] }, () => ({
78+
result: [summary("studio", "connected")],
79+
}));
80+
yield* serve(broker, "laptop", { laptop: ["read"], studio: ["read"] }, () => ({
81+
result: [summary("laptop", "connected"), summary("studio", "connecting")],
82+
}));
83+
// An app that stopped answering must not hold up the others' environments.
84+
yield* broker
85+
.connect("asleep", { asleep: ["read"], laptop: ["read"] })
86+
.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }));
87+
const listing = yield* broker
88+
.forward("listEnvironments", [], [])
89+
.pipe(Effect.forkScoped({ startImmediately: true }));
90+
yield* TestClock.adjust("10 seconds");
91+
expect(yield* Fiber.join(listing)).toEqual([
92+
summary("laptop", "connected"),
93+
summary("studio", "connected"),
94+
]);
95+
// Having timed out, the silent app no longer takes calls another app can run.
96+
expect(yield* broker.forward("listProjects", ["laptop"], ["laptop"])).toEqual([
97+
summary("laptop", "connected"),
98+
summary("studio", "connecting"),
99+
]);
100+
}),
101+
).pipe(Effect.provide(NodeServices.layer)),
102+
);
103+
64104
it.effect("fails calls no connected app can run, and reports app errors", () =>
65105
Effect.scoped(
66106
Effect.gen(function* () {

0 commit comments

Comments
 (0)