Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/default-callback-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

fix: `addMcpServer` now warns when it falls back to the default OAuth callback URL because no `callbackPath` was provided. The default path embeds the agent instance name and only works when the Worker routes the agents prefix through `routeAgentRequest`; previously the fallback was silent whenever `sendIdentityOnConnect` was `true`, letting OAuth flows hang in `AUTHENTICATING` with no signal.
21 changes: 18 additions & 3 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10779,9 +10779,24 @@ export class Agent<
let callbackUrl: string | undefined;
if (resolvedCallbackHost) {
const normalizedHost = resolvedCallbackHost.replace(/\/$/, "");
callbackUrl = resolvedCallbackPath
? `${normalizedHost}/${resolvedCallbackPath.replace(/^\//, "")}`
: `${normalizedHost}/${resolvedAgentsPrefix}/${camelCaseToKebabCase(this._ParentClass.name)}/${this.name}/callback`;
if (resolvedCallbackPath) {
callbackUrl = `${normalizedHost}/${resolvedCallbackPath.replace(/^\//, "")}`;
} else {
// The guard above throws only for sendIdentityOnConnect:false with an
// explicitly provided callbackHost. Every path that still lands here —
// sendIdentityOnConnect:true, or an auto-derived host regardless of
// that option — gets a warning instead: the default URL leaks the
// instance name and only works when the Worker routes the agents
// prefix through routeAgentRequest (#1378). Escalating the derived-
// host case to a throw would break currently-working setups.
callbackUrl = `${normalizedHost}/${resolvedAgentsPrefix}/${camelCaseToKebabCase(this._ParentClass.name)}/${this.name}/callback`;
console.warn(
`addMcpServer("${serverName}"): no callbackPath was provided, falling back to the default OAuth callback URL ${callbackUrl}. ` +
`This exposes the agent instance name in the URL and only works if your Worker routes /${resolvedAgentsPrefix}/* ` +
`through routeAgentRequest. If that route is handled elsewhere (e.g. static assets), the OAuth flow will hang in ` +
`AUTHENTICATING — pass an explicit callbackPath and route it to this agent via getAgentByName.`
);
}
}

const id = requestedId ?? nanoid(8);
Expand Down
39 changes: 39 additions & 0 deletions packages/agents/src/tests/agents/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,45 @@ export class TestHttpMcpDedupAgent extends Agent {
};
}
}

// #1378: capture console.warn during addMcpServer to assert the default
// callback URL warning fires (and stays silent with an explicit path).
private async _captureWarnings(
fn: () => Promise<unknown>
): Promise<string[]> {
const captured: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
captured.push(args.map(String).join(" "));
};
try {
await fn();
} catch {
// connection failures from the mocked transport are expected
} finally {
console.warn = originalWarn;
}
return captured;
}

async testDefaultCallbackUrlWarns() {
const warnings = await this._captureWarnings(() =>
this.addMcpServer("warn-server", "https://mcp.example.com/warn", {
callbackHost: "https://example.com"
})
);
return { warnings };
}

async testExplicitCallbackPathDoesNotWarn() {
const warnings = await this._captureWarnings(() =>
this.addMcpServer("no-warn-server", "https://mcp.example.com/no-warn", {
callbackHost: "https://example.com",
callbackPath: "/auth/mcp-callback"
})
);
return { warnings };
}
}

/**
Expand Down
37 changes: 37 additions & 0 deletions packages/agents/src/tests/mcp/add-mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,43 @@ describe("addMcpServer callbackPath enforcement", () => {
});
});

describe("addMcpServer default callback URL warning (#1378)", () => {
it("warns when the default callback URL is used without callbackPath", async () => {
const agentStub = await getAgentByName(
env.TestHttpMcpDedupAgent,
"test-default-callback-warn"
);
const { warnings } = (await agentStub.testDefaultCallbackUrlWarns()) as {
warnings: string[];
};
const warning = warnings.find((w) =>
w.includes("falling back to the default OAuth callback URL")
);
expect(warning).toBeDefined();
expect(warning).toContain('addMcpServer("warn-server")');
// Default URL embeds the agents prefix and the instance name
expect(warning).toContain("https://example.com/agents/");
expect(warning).toContain("/test-default-callback-warn/callback");
expect(warning).toContain("callbackPath");
});

it("does not warn when an explicit callbackPath is provided", async () => {
const agentStub = await getAgentByName(
env.TestHttpMcpDedupAgent,
"test-explicit-callback-no-warn"
);
const { warnings } =
(await agentStub.testExplicitCallbackPathDoesNotWarn()) as {
warnings: string[];
};
expect(
warnings.filter((w) =>
w.includes("falling back to the default OAuth callback URL")
)
).toEqual([]);
});
});

describe("addMcpServer API overloads", () => {
describe("new options-based API", () => {
it("should resolve options object with all fields", async () => {
Expand Down
Loading