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
5 changes: 5 additions & 0 deletions .changeset/rare-windows-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

Escape authError to prevent XSS attacks and store it in the connection state to avoid needing script tags to display error.
1 change: 1 addition & 0 deletions examples/mcp-client/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function App() {
placeholder: {
auth_url: null,
capabilities: null,
error: null,
instructions: null,
name: serverName,
server_url: serverUrl,
Expand Down
22 changes: 5 additions & 17 deletions examples/mcp-client/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import { Agent, routeAgentRequest } from "agents";
import type { MCPClientOAuthResult } from "agents/mcp";

export class MyAgent extends Agent {
onStart() {
// Optionally configure OAuth callback. Here we use popup-closing behavior since we're opening a window on the client
this.mcp.configureOAuthCallback({
customHandler: (result: MCPClientOAuthResult) => {
if (result.authSuccess) {
return new Response("<script>window.close();</script>", {
headers: { "content-type": "text/html" },
status: 200
});
} else {
const safeError = JSON.stringify(result.authError || "Unknown error");
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this hot garbage

return new Response(
`<script>alert('Authentication failed: ' + ${safeError}); window.close();</script>`,
{
headers: { "content-type": "text/html" },
status: 200
}
);
}
customHandler: () => {
return new Response("<script>window.close();</script>", {
headers: { "content-type": "text/html" },
status: 200
});
}
});
}
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@cfworker/json-schema": "^4.1.1",
"@modelcontextprotocol/sdk": "1.25.2",
"cron-schedule": "^6.0.0",
"escape-html": "^1.0.3",
"json-schema": "^0.4.0",
"json-schema-to-typescript": "^15.0.4",
"mimetext": "^3.0.28",
Expand All @@ -39,6 +40,7 @@
"@ai-sdk/openai": "^3.0.23",
"@ai-sdk/react": "^3.0.66",
"@cloudflare/workers-oauth-provider": "^0.2.2",
"@types/escape-html": "^1.0.4",
"@types/react": "^19.2.10",
"@types/yargs": "^17.0.35",
"ai": "^6.0.64",
Expand Down
2 changes: 2 additions & 0 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export type MCPServer = {
// Scope outside of that can't be relied upon because when the DO sleeps, there's no way
// to communicate a change to a non-ready state.
state: MCPConnectionState;
error: string | null;
instructions: string | null;
capabilities: ServerCapabilities | null;
};
Expand Down Expand Up @@ -3182,6 +3183,7 @@ export class Agent<
mcpState.servers[server.id] = {
auth_url: server.auth_url,
capabilities: serverConn?.serverCapabilities ?? null,
error: serverConn?.connectionError ?? null,
instructions: serverConn?.instructions ?? null,
name: server.name,
server_url: server.server_url,
Expand Down
1 change: 1 addition & 0 deletions packages/agents/src/mcp/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export type MCPDiscoveryResult = {
export class MCPClientConnection {
client: Client;
connectionState: MCPConnectionState = MCPConnectionState.CONNECTING;
connectionError: string | null = null;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add this string as a first class thing which can be bubbled up

lastConnectedTransport: BaseTransportType | undefined;
instructions?: string;
tools: Tool[] = [];
Expand Down
134 changes: 63 additions & 71 deletions packages/agents/src/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import escapeHtml from "escape-html";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this library is already in the bundle as a transient dep so thats handy

import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type {
CallToolRequest,
Expand Down Expand Up @@ -44,6 +45,13 @@ export type MCPServerOptions = {
};
};

/**
* Result of an OAuth callback request
*/
export type MCPOAuthCallbackResult =
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type didnt exist but it was nice to define it

| { serverId: string; authSuccess: true; authError?: undefined }
| { serverId: string; authSuccess: false; authError: string };

/**
* Options for registering an MCP server
*/
Expand Down Expand Up @@ -187,6 +195,19 @@ export class MCPClientManager {
);
}

private failConnection(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this standardises what happens when we have a connection so we know which one to mark as failed. extracting this logic means I dont have to repeat it for the 3 occasions this happens in

serverId: string,
error: string
): MCPOAuthCallbackResult {
this.clearServerAuthUrl(serverId);
if (this.mcpConnections[serverId]) {
this.mcpConnections[serverId].connectionState = MCPConnectionState.FAILED;
this.mcpConnections[serverId].connectionError = error;
}
this._onServerStateChanged.fire();
return { serverId, authSuccess: false, authError: error };
}

jsonSchema: typeof import("ai").jsonSchema | undefined;

/**
Expand Down Expand Up @@ -663,19 +684,19 @@ export class MCPClientManager {
return servers.some((server) => server.id === serverId);
}

async handleCallbackRequest(req: Request) {
async handleCallbackRequest(req: Request): Promise<MCPOAuthCallbackResult> {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const errorDescription = url.searchParams.get("error_description");

// Early validation - these throw because we can't identify the connection
if (!state) {
throw new Error("Unauthorized: no state provided");
}

const serverId = this.extractServerIdFromState(state);

if (!serverId) {
throw new Error(
"No serverId found in state parameter. Expected format: {nonce}.{serverId}"
Expand All @@ -684,7 +705,6 @@ export class MCPClientManager {

const servers = this.getServersFromStorage();
const serverExists = servers.some((server) => server.id === serverId);

if (!serverExists) {
throw new Error(
`No server found with id "${serverId}". Was the request matched with \`isCallbackRequest()\`?`
Expand All @@ -695,89 +715,61 @@ export class MCPClientManager {
throw new Error(`Could not find serverId: ${serverId}`);
}

// We have a valid connection - all errors from here should fail the connection
const conn = this.mcpConnections[serverId];
if (!conn.options.transport.authProvider) {
throw new Error(
"Trying to finalize authentication for a server connection without an authProvider"
);
}

const authProvider = conn.options.transport.authProvider;
authProvider.serverId = serverId;
try {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from here we have a connection id so any errors are wrapped in this try catch and we can fail the connection gracefully.

if (!conn.options.transport.authProvider) {
throw new Error(
"Trying to finalize authentication for a server connection without an authProvider"
);
}

// Two-phase state validation: check first (non-destructive), consume later
// This prevents DoS attacks where attacker consumes valid state before legitimate callback
const stateValidation = await authProvider.checkState(state);
if (!stateValidation.valid) {
this.clearServerAuthUrl(serverId);
if (this.mcpConnections[serverId]) {
this.mcpConnections[serverId].connectionState =
MCPConnectionState.FAILED;
const authProvider = conn.options.transport.authProvider;
authProvider.serverId = serverId;

// Two-phase state validation: check first (non-destructive), consume later
// This prevents DoS attacks where attacker consumes valid state before legitimate callback
const stateValidation = await authProvider.checkState(state);
if (!stateValidation.valid) {
throw new Error(stateValidation.error || "Invalid state");
}
this._onServerStateChanged.fire();
return {
serverId,
authSuccess: false,
authError: stateValidation.error || "Invalid state"
};
}

if (error) {
return {
serverId,
authSuccess: false,
authError: errorDescription || error
};
}
if (error) {
// Escape external OAuth error params to prevent XSS
throw new Error(escapeHtml(errorDescription || error));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the big baddie xss

}

if (!code) {
throw new Error("Unauthorized: no code provided");
}
if (!code) {
throw new Error("Unauthorized: no code provided");
}

if (
this.mcpConnections[serverId].connectionState ===
MCPConnectionState.READY ||
this.mcpConnections[serverId].connectionState ===
MCPConnectionState.CONNECTED
) {
this.clearServerAuthUrl(serverId);
return {
serverId,
authSuccess: true
};
}
// Already authenticated - just return success
if (
conn.connectionState === MCPConnectionState.READY ||
conn.connectionState === MCPConnectionState.CONNECTED
) {
this.clearServerAuthUrl(serverId);
return { serverId, authSuccess: true };
}
Comment on lines +747 to +754
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

happy path making sure to clear the auth url. Maybe we also need to clear the auth error also but it shouldnt be set.


if (
this.mcpConnections[serverId].connectionState !==
MCPConnectionState.AUTHENTICATING
) {
throw new Error(
`Failed to authenticate: the client is in "${this.mcpConnections[serverId].connectionState}" state, expected "authenticating"`
);
}
if (conn.connectionState !== MCPConnectionState.AUTHENTICATING) {
throw new Error(
`Failed to authenticate: the client is in "${conn.connectionState}" state, expected "authenticating"`
);
}

try {
await authProvider.consumeState(state);
await conn.completeAuthorization(code);
await authProvider.deleteCodeVerifier();
this.clearServerAuthUrl(serverId);
conn.connectionError = null;
this._onServerStateChanged.fire();
Comment on lines 762 to 767
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other happy path.


return {
serverId,
authSuccess: true
};
} catch (authError) {
const errorMessage =
authError instanceof Error ? authError.message : String(authError);

this._onServerStateChanged.fire();

return {
serverId,
authSuccess: false,
authError: errorMessage
};
return { serverId, authSuccess: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return this.failConnection(serverId, message);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracted logic

}
}

Expand Down
Loading
Loading