Skip to content

Commit cce97a0

Browse files
committed
Stop headless MCP connect from advertising interactive OAuth
Headless exec was registering an onAuthURL callback it could never complete, so OAuth-backed MCP servers hung automation. Gate the callback on interactiveAuth: TUI keeps the browser flow; exec and other headless hosts reuse stored tokens only.
1 parent fb18f71 commit cce97a0

4 files changed

Lines changed: 41 additions & 1 deletion

File tree

src/agent/tools.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export type MCPServerState =
145145
| { name: string; state: "failed"; error: string };
146146

147147
export type MCPConnectCallbacks = {
148+
// Headless hosts must not advertise an auth callback they cannot complete.
149+
// Its presence is how the MCP client decides an OAuth flow is interactive.
150+
interactiveAuth: boolean;
148151
// Fired whenever a server's connection state changes.
149152
onStatus: (state: MCPServerState) => void;
150153
// Fired after a server connects and its tools are registered, with the new
@@ -360,7 +363,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
360363
callbacks.onStatus({ name: config.name, state: "connecting" });
361364
const result = await connectMCPServer(config, {
362365
stderr: "ignore",
363-
onAuthURL: (name, url) => callbacks.onStatus({ name, state: "needs-auth", url }),
366+
...(callbacks.interactiveAuth
367+
? { onAuthURL: (name: string, url: string) => callbacks.onStatus({ name, state: "needs-auth", url }) }
368+
: {}),
364369
// Mid-session re-auth fires needs-auth again without a later connected
365370
// event. Re-emit connected only when tools are already registered so
366371
// first-connect still waits for the real post-connect status.

src/exec/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
591591
if (agentToolset.connectMCP !== undefined) {
592592
await agentToolset
593593
.connectMCP({
594+
interactiveAuth: false,
594595
onStatus: (status) => {
595596
if (status.state === "connected") {
596597
connectedMcp = [

src/tui/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2471,6 +2471,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
24712471
void toolset
24722472
.connectMCP(
24732473
{
2474+
interactiveAuth: true,
24742475
onStatus: (status) => {
24752476
mcpStates.set(status.name, status);
24762477
emitter.emit("mcp.status", status);

tests/unit/tui/agent-tools.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const mockPosixTools = {
2727
// mock.module below runs, making the "restore" a no-op.
2828
const realToolsPosix = { ...(await import("@intx/tools-posix")) };
2929
const realPosixToolPlugins = { ...(await import("../../../src/agent/posix-tool-plugins.js")) };
30+
const realMcpClient = { ...(await import("../../../src/mcp/client.js")) };
3031
const realMcpPlugin = { ...(await import("../../../src/mcp/plugin.js")) };
3132
const realPathEscapePlugin = { ...(await import("../../../src/plugins/path-escape-plugin.js")) };
3233
const realAuthzPlugin = { ...(await import("../../../src/plugins/authz-plugin.js")) };
@@ -47,6 +48,17 @@ mock.module("../../../src/agent/posix-tool-plugins.js", () => ({
4748
buildCorePosixToolPlugins: () => [],
4849
}));
4950

51+
const mockConnectMCPServer = mock(async (config: { name: string }) => ({
52+
ok: false as const,
53+
serverName: config.name,
54+
error: "not connected",
55+
}));
56+
57+
mock.module("../../../src/mcp/client.js", () => ({
58+
...realMcpClient,
59+
connectMCPServer: mockConnectMCPServer,
60+
}));
61+
5062
mock.module("../../../src/mcp/plugin.js", () => ({
5163
mcpClientToAgentTools: () => [],
5264
}));
@@ -113,6 +125,7 @@ mock.module("../../../src/agent/director.js", () => ({
113125
afterAll(() => {
114126
mock.module("@intx/tools-posix", () => realToolsPosix);
115127
mock.module("../../../src/agent/posix-tool-plugins.js", () => realPosixToolPlugins);
128+
mock.module("../../../src/mcp/client.js", () => realMcpClient);
116129
mock.module("../../../src/mcp/plugin.js", () => realMcpPlugin);
117130
mock.module("../../../src/plugins/path-escape-plugin.js", () => realPathEscapePlugin);
118131
mock.module("../../../src/plugins/authz-plugin.js", () => realAuthzPlugin);
@@ -304,6 +317,26 @@ test("default session registers task and search_agents", async () => {
304317
expect(names).toContain("search_agents");
305318
});
306319

320+
test("headless MCP connection does not wait for interactive OAuth", async () => {
321+
mockConnectMCPServer.mockClear();
322+
const toolset = await createAgentToolset({
323+
cwd: "/fake",
324+
permissionGate: fakePermissionGate,
325+
onOperatorGate: async () => ({ kind: "cancel" }),
326+
mcpServers: [{ name: "granola", url: "https://example.test/mcp" }],
327+
mcpServersSource: "global",
328+
});
329+
330+
await toolset.connectMCP({
331+
interactiveAuth: false,
332+
onStatus: () => {},
333+
onToolsChanged: () => {},
334+
});
335+
336+
expect(mockConnectMCPServer).toHaveBeenCalledTimes(1);
337+
expect(mockConnectMCPServer.mock.calls[0]?.[1]?.onAuthURL).toBeUndefined();
338+
});
339+
307340
test("dispose calls posixTools.dispose", async () => {
308341
mockDispose.mockClear();
309342

0 commit comments

Comments
 (0)