Skip to content

Commit 731d43d

Browse files
committed
fix(plugins): fall back to Codex CLI MCP login
- Detect missing MCP servers through nested causes - Close unused OAuth windows and document Codex sign-in behavior
1 parent 541b561 commit 731d43d

4 files changed

Lines changed: 156 additions & 4 deletions

File tree

apps/server/src/plugins/McpOAuthRuntime.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
11
import { assert, it } from "@effect/vitest";
22

33
import {
4+
codexMcpLoginArgs,
45
findClaudeMcpAuthorizationUrl,
6+
isCodexMcpServerMissingError,
57
parseClaudeMcpStatusOutput,
68
parseCodexMcpStatusOutput,
79
parseCursorMcpStatusOutput,
810
validateMcpOAuthCallback,
911
} from "./McpOAuthRuntime.ts";
1012

13+
it("detects Codex missing-server errors through the cause chain", () => {
14+
assert.strictEqual(
15+
isCodexMcpServerMissingError(new Error("No MCP server named 'figma' found.")),
16+
true,
17+
);
18+
assert.strictEqual(
19+
isCodexMcpServerMissingError(
20+
new Error("Codex is unavailable for MCP start.", {
21+
cause: new Error("No MCP server named 'figma' found."),
22+
}),
23+
),
24+
true,
25+
);
26+
assert.strictEqual(isCodexMcpServerMissingError(new Error("authentication failed")), false);
27+
});
28+
29+
it("seeds Codex CLI login with the listed MCP URL when app-server cannot see the server", () => {
30+
assert.deepStrictEqual(codexMcpLoginArgs("figma"), ["mcp", "login", "figma"]);
31+
assert.deepStrictEqual(codexMcpLoginArgs("figma", "https://mcp.figma.com/mcp"), [
32+
"-c",
33+
'mcp_servers.figma.url="https://mcp.figma.com/mcp"',
34+
"mcp",
35+
"login",
36+
"figma",
37+
]);
38+
});
39+
1140
it("selects Claude OAuth URLs instead of unrelated output links", () => {
1241
const authorizationUrl =
1342
"https://accounts.example.com/authorize?response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A43123%2Fcallback";

apps/server/src/plugins/McpOAuthRuntime.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ const CodexMcpServer = Schema.Struct({
192192
const decodeCodexMcpServers = Schema.decodeUnknownOption(
193193
Schema.fromJsonString(Schema.Array(CodexMcpServer)),
194194
);
195+
const CodexMcpServerUrl = Schema.Struct({
196+
transport: Schema.optional(
197+
Schema.Struct({
198+
url: Schema.optional(Schema.String),
199+
}),
200+
),
201+
});
202+
const decodeCodexMcpServerUrl = Schema.decodeUnknownOption(
203+
Schema.fromJsonString(CodexMcpServerUrl),
204+
);
195205

196206
function cleanedCliLine(value: string): string {
197207
return (
@@ -210,6 +220,35 @@ function findHttpUrl(value: string): string | null {
210220
return match?.replace(/[),.;]+$/u, "") ?? null;
211221
}
212222

223+
export function isCodexMcpServerMissingError(error: unknown): boolean {
224+
const seen = new Set<unknown>();
225+
let current: unknown = error;
226+
while (current && !seen.has(current)) {
227+
seen.add(current);
228+
const message =
229+
current instanceof Error
230+
? current.message
231+
: typeof current === "object" && current !== null && "message" in current
232+
? String(Reflect.get(current, "message"))
233+
: String(current);
234+
if (/no mcp server named/iu.test(message)) return true;
235+
current =
236+
current instanceof Error
237+
? current.cause
238+
: typeof current === "object" && current !== null && "cause" in current
239+
? Reflect.get(current, "cause")
240+
: undefined;
241+
}
242+
return false;
243+
}
244+
245+
export function codexMcpLoginArgs(name: string, url?: string | null): ReadonlyArray<string> {
246+
const trimmedUrl = url?.trim();
247+
return trimmedUrl
248+
? ["-c", `mcp_servers.${name}.url=${JSON.stringify(trimmedUrl)}`, "mcp", "login", name]
249+
: ["mcp", "login", name];
250+
}
251+
213252
export function findClaudeMcpAuthorizationUrl(value: string): string | null {
214253
// eslint-disable-next-line no-control-regex -- URLs end before terminal control sequences.
215254
for (const match of value.matchAll(/https?:\/\/[^\s\u0007\u001B]+/gu)) {
@@ -437,7 +476,7 @@ export const make = (options: McpOAuthRuntimeOptions = {}) =>
437476
return next;
438477
});
439478

440-
const runCodexSession = (key: string, session: ActiveSession) =>
479+
const runCodexAppServerLogin = (session: ActiveSession) =>
441480
Effect.scoped(
442481
Effect.gen(function* () {
443482
const configured = yield* commandFor("codex", "codex");
@@ -484,6 +523,7 @@ export const make = (options: McpOAuthRuntimeOptions = {}) =>
484523
capabilities: { experimentalApi: true },
485524
});
486525
yield* client.notify("initialized", undefined);
526+
yield* client.request("config/mcpServer/reload", undefined).pipe(Effect.ignore);
487527
const response = yield* client.request("mcpServer/oauth/login", {
488528
name: session.name,
489529
timeoutSecs: 600,
@@ -539,7 +579,87 @@ export const make = (options: McpOAuthRuntimeOptions = {}) =>
539579
});
540580
}
541581
}),
542-
).pipe(
582+
);
583+
584+
const runCodexCliLogin = (session: ActiveSession) =>
585+
Effect.scoped(
586+
Effect.gen(function* () {
587+
const configured = yield* commandFor("codex", "codex");
588+
if (!configured) {
589+
return yield* new McpOAuthProviderUnavailableError({
590+
operation: "start",
591+
harness: "codex",
592+
serverName: session.name,
593+
});
594+
}
595+
const listed = yield* processRunner
596+
.run({
597+
command: configured.command,
598+
args: ["mcp", "get", session.name, "--json"],
599+
cwd,
600+
env: configured.env,
601+
timeout: "30 seconds",
602+
maxOutputBytes: 512 * 1024,
603+
outputMode: "truncate",
604+
})
605+
.pipe(Effect.option);
606+
const listedUrl =
607+
Option.isSome(listed) && listed.value.code === 0
608+
? Option.match(decodeCodexMcpServerUrl(listed.value.stdout), {
609+
onNone: () => null,
610+
onSome: (server) => server.transport?.url?.trim() || null,
611+
})
612+
: null;
613+
const environment = configured.env;
614+
const spawnCommand = yield* resolveSpawnCommand(
615+
configured.command,
616+
[...codexMcpLoginArgs(session.name, listedUrl)],
617+
{
618+
env: environment,
619+
extendEnv: true,
620+
},
621+
);
622+
const child = yield* spawner.spawn(
623+
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
624+
cwd,
625+
env: environment,
626+
extendEnv: true,
627+
shell: spawnCommand.shell,
628+
forceKillAfter: "2 seconds",
629+
}),
630+
);
631+
yield* Deferred.succeed(session.started, {
632+
authorizationUrl: "",
633+
callbackRequired: false,
634+
});
635+
const exitCode = yield* Effect.raceFirst(
636+
child.exitCode,
637+
Deferred.await(session.cancelled).pipe(
638+
Effect.flatMap(() =>
639+
Effect.fail(
640+
new McpOAuthAuthenticationCancelledError({
641+
operation: "start",
642+
harness: "codex",
643+
serverName: session.name,
644+
}),
645+
),
646+
),
647+
),
648+
);
649+
if (exitCode !== 0) {
650+
return yield* new McpOAuthAuthenticationFailedError({
651+
operation: "start",
652+
harness: "codex",
653+
serverName: session.name,
654+
exitCode,
655+
});
656+
}
657+
}),
658+
);
659+
660+
const runCodexSession = (key: string, session: ActiveSession) =>
661+
runCodexAppServerLogin(session).pipe(
662+
Effect.catchIf(isCodexMcpServerMissingError, () => runCodexCliLogin(session)),
543663
Effect.mapError((cause) =>
544664
isMcpOAuthRuntimeError(cause)
545665
? cause

apps/web/src/components/settings/pluginMarketplace/PluginDetail.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@ function McpAuthentication({ plugin }: { readonly plugin: PluginMarketplaceDetai
415415
} else if (window.desktopBridge) {
416416
await openAuthorizationUrl(result.authorizationUrl);
417417
}
418+
} else {
419+
reservedWindow?.close();
418420
}
419421
toastManager.add({
420422
type: "success",

docs/user/plugins.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ that plugin's enabled skills with the turn, including in an existing thread. Sta
3333
changing MCP servers or apps so the harness can refresh those longer-lived connections.
3434

3535
Remote HTTP MCP servers that require OAuth have an **MCP authentication** section after
36-
installation. Select **Connect** to open the provider's authorization page. Codex completes the
37-
loopback flow in the browser. Claude Code may ask you to paste the full callback URL back into the
36+
installation. Select **Connect** to open the provider's authorization page. Codex usually
37+
completes the loopback flow in the browser; if it opens its own sign-in window instead, finish
38+
there and return to T3 Code. Claude Code may ask you to paste the full callback URL back into the
3839
plugin page when the environment is remote. Connection status and **Disconnect** use each
3940
harness's native credential store, so T3 Code never keeps a separate copy of the access or refresh
4041
token. Cursor connections continue in Cursor's own settings. Local standard-input MCP servers do

0 commit comments

Comments
 (0)