Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/lazy-mcp-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"agents": patch
---

Make `callbackHost` optional in `addMcpServer` for non-OAuth servers

Previously, `addMcpServer()` always required a `callbackHost` (either explicitly or derived from the request context) and eagerly created an OAuth auth provider, even when connecting to MCP servers that do not use OAuth. This made simple non-OAuth connections unnecessarily difficult, especially from WebSocket callable methods where the request context origin is unreliable.

Now, `callbackHost` and the OAuth auth provider are only required when the MCP server actually needs OAuth (returns a 401/AUTHENTICATING state). For non-OAuth servers, `addMcpServer("name", url)` works with no additional options. If an OAuth server is encountered without a `callbackHost`, a clear error is thrown: "This MCP server requires OAuth authentication. Provide callbackHost in addMcpServer options to enable the OAuth flow."

The restore-from-storage flow also handles missing callback URLs gracefully, skipping auth provider creation for non-OAuth servers.
50 changes: 32 additions & 18 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3787,43 +3787,51 @@ export class Agent<
resolvedOptions = options;
}

// Enforce callbackPath when sendIdentityOnConnect is false
if (!this._resolvedOptions.sendIdentityOnConnect && !resolvedCallbackPath) {
// Enforce callbackPath when sendIdentityOnConnect is false and callbackHost is provided
if (
!this._resolvedOptions.sendIdentityOnConnect &&
resolvedCallbackHost &&
!resolvedCallbackPath
) {
throw new Error(
"callbackPath is required in addMcpServer options when sendIdentityOnConnect is false — " +
"the default callback URL would expose the instance name. " +
"Provide a callbackPath and route the callback request to this agent via getAgentByName."
);
}

// If callbackHost is not provided, derive it from the current request
// Try to derive callbackHost from the current request if not explicitly provided
if (!resolvedCallbackHost) {
const { request } = getCurrentAgent();
if (!request) {
throw new Error(
"callbackHost is required when not called within a request context"
);
if (request) {
const requestUrl = new URL(request.url);
resolvedCallbackHost = `${requestUrl.protocol}//${requestUrl.host}`;
}

// Extract the origin from the request
const requestUrl = new URL(request.url);
resolvedCallbackHost = `${requestUrl.protocol}//${requestUrl.host}`;
}

// Build the callback URL: use callbackPath if provided, otherwise default to /agents/{class}/{name}/callback
const normalizedHost = resolvedCallbackHost.replace(/\/$/, "");
const callbackUrl = resolvedCallbackPath
? `${normalizedHost}/${resolvedCallbackPath.replace(/^\//, "")}`
: `${normalizedHost}/${resolvedAgentsPrefix}/${camelCaseToKebabCase(this._ParentClass.name)}/${this.name}/callback`;
// Build the callback URL if we have a host (needed for OAuth, optional for non-OAuth servers)
let callbackUrl: string | undefined;
if (resolvedCallbackHost) {
const normalizedHost = resolvedCallbackHost.replace(/\/$/, "");
callbackUrl = resolvedCallbackPath
? `${normalizedHost}/${resolvedCallbackPath.replace(/^\//, "")}`
: `${normalizedHost}/${resolvedAgentsPrefix}/${camelCaseToKebabCase(this._ParentClass.name)}/${this.name}/callback`;
}

// TODO: make zod/ai sdk more performant and remove this
// Late initialization of jsonSchemaFn (needed for getAITools)
await this.mcp.ensureJsonSchema();

const id = nanoid(8);

const authProvider = this.createMcpOAuthProvider(callbackUrl);
authProvider.serverId = id;
// Only create authProvider if we have a callbackUrl (needed for OAuth servers)
let authProvider:
| ReturnType<typeof this.createMcpOAuthProvider>
| undefined;
if (callbackUrl) {
authProvider = this.createMcpOAuthProvider(callbackUrl);
authProvider.serverId = id;
}

// Use the transport type specified in options, or default to "auto"
const transportType: TransportType =
Expand Down Expand Up @@ -3871,6 +3879,12 @@ export class Agent<
}

if (result.state === MCPConnectionState.AUTHENTICATING) {
if (!callbackUrl) {
throw new Error(
"This MCP server requires OAuth authentication. " +
"Provide callbackHost in addMcpServer options to enable the OAuth flow."
);
}
return { id, state: result.state, authUrl: result.authUrl };
}

Expand Down
29 changes: 16 additions & 13 deletions packages/agents/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export type MCPOAuthCallbackResult =
export type RegisterServerOptions = {
url: string;
name: string;
callbackUrl: string;
callbackUrl?: string;
client?: ConstructorParameters<typeof Client>[1];
transport?: MCPTransportOptions;
authUrl?: string;
Expand Down Expand Up @@ -322,17 +322,20 @@ export class MCPClientManager {
? JSON.parse(server.server_options)
: null;

const authProvider = this._createAuthProviderFn
? this._createAuthProviderFn(server.callback_url)
: this.createAuthProvider(
server.id,
server.callback_url,
clientName,
server.client_id ?? undefined
);
authProvider.serverId = server.id;
if (server.client_id) {
authProvider.clientId = server.client_id;
let authProvider: AgentMcpOAuthProvider | undefined;
if (server.callback_url) {
authProvider = this._createAuthProviderFn
? this._createAuthProviderFn(server.callback_url)
: this.createAuthProvider(
server.id,
server.callback_url,
clientName,
server.client_id ?? undefined
);
authProvider.serverId = server.id;
if (server.client_id) {
authProvider.clientId = server.client_id;
}
}

// Create the in-memory connection object (no need to save to storage - we just read from it!)
Expand Down Expand Up @@ -611,7 +614,7 @@ export class MCPClientManager {
id,
name: options.name,
server_url: options.url,
callback_url: options.callbackUrl,
callback_url: options.callbackUrl ?? "",
client_id: options.clientId ?? null,
auth_url: options.authUrl ?? null,
server_options: JSON.stringify({
Expand Down
5 changes: 5 additions & 0 deletions packages/agents/src/tests/agents/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ export class TestAddMcpServerAgent extends Agent<Record<string, unknown>> {
return this.lastResolvedArgs!;
}

async testNoOptions(name: string, url: string) {
await this.addMcpServer(name, url);
return this.lastResolvedArgs!;
}

async testLegacyApiWithOptions(
name: string,
url: string,
Expand Down
16 changes: 16 additions & 0 deletions packages/agents/src/tests/agents/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,20 @@ export class TestNoIdentityAgent extends Agent<
};
}
}

// Test method: calls addMcpServer without callbackHost — should skip callbackPath enforcement
async testAddMcpServerWithoutCallbackHost(): Promise<{
threw: boolean;
message: string;
}> {
try {
await this.addMcpServer("test-server", "https://mcp.example.com");
return { threw: false, message: "" };
} catch (err) {
return {
threw: true,
message: err instanceof Error ? err.message : String(err)
};
}
}
}
39 changes: 39 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 @@ -38,6 +38,25 @@ describe("addMcpServer callbackPath enforcement", () => {
);
});

it("should not throw enforcement error when sendIdentityOnConnect is false and no callbackHost is provided", async () => {
const agentStub = await getAgentByName(
env.TestNoIdentityAgent,
"test-no-callback-host"
);
const result =
(await agentStub.testAddMcpServerWithoutCallbackHost()) as unknown as {
threw: boolean;
message: string;
};

// May throw for connection error, but should NOT throw the callbackPath enforcement error
if (result.threw) {
expect(result.message).not.toContain(
"callbackPath is required in addMcpServer options when sendIdentityOnConnect is false"
);
}
});

it("should not throw enforcement error when sendIdentityOnConnect is false and callbackPath is provided", async () => {
const agentStub = await getAgentByName(
env.TestNoIdentityAgent,
Expand Down Expand Up @@ -103,6 +122,26 @@ describe("addMcpServer API overloads", () => {
client: undefined
});
});

it("should work with no options at all (no callbackHost needed for non-OAuth servers)", async () => {
const agentStub = await getAgentByName(
env.TestAddMcpServerAgent,
"test-no-options"
);
const result = await agentStub.testNoOptions(
"simple-server",
"https://simple.example.com"
);

expect(result).toEqual({
serverName: "simple-server",
url: "https://simple.example.com",
callbackHost: undefined,
agentsPrefix: "agents",
transport: undefined,
client: undefined
});
});
});

describe("legacy positional API", () => {
Expand Down
Loading