OAuth で保護された MCP サーバー(Slack や Notion など)に接続する場合、エージェントがデータへアクセスする前にユーザー認証が必要です。このガイドでは、途切れのない認可のための OAuth フローの実装を説明します。
- サーバー URL を指定して
addMcpServer()を呼びます - OAuth が必要な場合、すぐには接続せず
authUrlが返ります - ユーザーに
authUrlを提示します(リダイレクト、ポップアップ、またはリンク) - ユーザーはプロバイダーのサイトで認証します
- プロバイダーはエージェントのコールバック URL へ戻します
- エージェントが接続を自動で完了します
MCP クライアントは組み込みの DurableObjectOAuthClientProvider で OAuth 状態を安全に管理します。nonce とサーバー ID を保存し、コールバック時に検証し、使用後または期限切れ後にクリーンアップします。
OAuth 保護サーバーへ接続するときは、authUrl が返ったかを確認します。あれば、ユーザーをリダイレクトして認可を完了させます。
export class MyAgent extends Agent {
async onRequest(request) {
const url = new URL(request.url);
if (url.pathname.endsWith("/connect") && request.method === "POST") {
const { id, authUrl } = await this.addMcpServer(
"Cloudflare Observability",
"https://observability.mcp.cloudflare.com/mcp",
);
if (authUrl) {
// OAuth required - redirect user to authorize
return Response.redirect(authUrl, 302);
}
// Already authenticated - connection complete
return Response.json({ serverId: id, status: "connected" });
}
return new Response("Not found", { status: 404 });
}
}export class MyAgent extends Agent<Env> {
async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.endsWith("/connect") && request.method === "POST") {
const { id, authUrl } = await this.addMcpServer(
"Cloudflare Observability",
"https://observability.mcp.cloudflare.com/mcp",
);
if (authUrl) {
// OAuth required - redirect user to authorize
return Response.redirect(authUrl, 302);
}
// Already authenticated - connection complete
return Response.json({ serverId: id, status: "connected" });
}
return new Response("Not found", { status: 404 });
}
}自動リダイレクトの代わりに、authUrl を次の形で提示できます。
- ポップアップウィンドウ: ダッシュボード型アプリでは
window.open(authUrl, '_blank', 'width=600,height=700') - クリック可能なリンク: 複数ステップのフローではボタンまたはリンクとして表示します
- ディープリンク: モバイルアプリではカスタム URL スキームを使います
OAuth が完了すると、プロバイダーはエージェントのコールバック URL へ戻します。デフォルトでは、認証成功時はアプリケーションのオリジンへリダイレクトし、失敗時はエラーメッセージ付きの HTML エラーページを表示します。
OAuth 完了後、ユーザーをアプリケーションへ戻します。
export class MyAgent extends Agent {
onStart() {
this.mcp.configureOAuthCallback({
successRedirect: "/dashboard",
errorRedirect: "/auth-error",
});
}
}export class MyAgent extends Agent<Env> {
onStart() {
this.mcp.configureOAuthCallback({
successRedirect: "/dashboard",
errorRedirect: "/auth-error",
});
}
}成功時は /dashboard、失敗時は /auth-error?error=<message> へ戻ります。
OAuth をポップアップで開いた場合は、完了時に自動で閉じます。
import { Agent } from "agents";
export class MyAgent extends Agent {
onStart() {
this.mcp.configureOAuthCallback({
customHandler: () => {
// Close the popup after OAuth completes
return new Response("<script>window.close();</script>", {
headers: { "content-type": "text/html" },
});
},
});
}
}import { Agent } from "agents";
export class MyAgent extends Agent<Env> {
onStart() {
this.mcp.configureOAuthCallback({
customHandler: () => {
// Close the popup after OAuth completes
return new Response("<script>window.close();</script>", {
headers: { "content-type": "text/html" },
});
},
});
}
}メインアプリケーションは、ポップアップが閉じたことを検知して接続状態を更新できます。OAuth が失敗すると接続状態は "failed" になり、エラーメッセージは UI 表示用に server.error へ保存されます。
useAgent フックで、WebSocket 経由のリアルタイム更新を受け取ります。
import { useAgent } from "agents/react";
import { useState } from "react";
function App() {
const [mcpState, setMcpState] = useState({
prompts: [],
resources: [],
servers: {},
tools: [],
});
const agent = useAgent({
agent: "my-agent",
name: "session-id",
onMcpUpdate: (mcpServers) => {
// Automatically called when MCP state changes!
setMcpState(mcpServers);
},
});
return (
<div>
{Object.entries(mcpState.servers).map(([id, server]) => (
<div key={id}>
<strong>{server.name}</strong>: {server.state}
{server.state === "authenticating" && server.auth_url && (
<button onClick={() => window.open(server.auth_url, "_blank")}>
Authorize
</button>
)}
{server.state === "failed" && server.error && (
<p className="error">{server.error}</p>
)}
</div>
))}
</div>
);
}import { useAgent } from "agents/react";
import { useState } from "react";
import type { MCPServersState } from "agents";
function App() {
const [mcpState, setMcpState] = useState<MCPServersState>({
prompts: [],
resources: [],
servers: {},
tools: [],
});
const agent = useAgent({
agent: "my-agent",
name: "session-id",
onMcpUpdate: (mcpServers: MCPServersState) => {
// Automatically called when MCP state changes!
setMcpState(mcpServers);
},
});
return (
<div>
{Object.entries(mcpState.servers).map(([id, server]) => (
<div key={id}>
<strong>{server.name}</strong>: {server.state}
{server.state === "authenticating" && server.auth_url && (
<button onClick={() => window.open(server.auth_url, "_blank")}>
Authorize
</button>
)}
{server.state === "failed" && server.error && (
<p className="error">{server.error}</p>
)}
</div>
))}
</div>
);
}onMcpUpdate コールバックは MCP 状態の変化時に自動で発火します。ポーリングは不要です。
エンドポイントで接続状態をポーリングします。
export class MyAgent extends Agent {
async onRequest(request) {
const url = new URL(request.url);
if (
url.pathname.endsWith("connection-status") &&
request.method === "GET"
) {
const mcpState = this.getMcpServers();
const connections = Object.entries(mcpState.servers).map(
([id, server]) => ({
serverId: id,
name: server.name,
state: server.state,
isReady: server.state === "ready",
needsAuth: server.state === "authenticating",
authUrl: server.auth_url,
}),
);
return Response.json(connections);
}
return new Response("Not found", { status: 404 });
}
}export class MyAgent extends Agent<Env> {
async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (
url.pathname.endsWith("connection-status") &&
request.method === "GET"
) {
const mcpState = this.getMcpServers();
const connections = Object.entries(mcpState.servers).map(
([id, server]) => ({
serverId: id,
name: server.name,
state: server.state,
isReady: server.state === "ready",
needsAuth: server.state === "authenticating",
authUrl: server.auth_url,
}),
);
return Response.json(connections);
}
return new Response("Not found", { status: 404 });
}
}接続状態の流れは次のとおりです。authenticating(OAuth が必要)→ connecting(セットアップ中)→ ready(利用可能)
OAuth が失敗すると接続状態は "failed" になり、エラーメッセージは server.error フィールドに保存されます。このエラーを UI に表示し、再試行できるようにします。
import { useAgent } from "agents/react";
import { useState } from "react";
function App() {
const [mcpState, setMcpState] = useState({
prompts: [],
resources: [],
servers: {},
tools: [],
});
const agent = useAgent({
agent: "my-agent",
name: "session-id",
onMcpUpdate: setMcpState,
});
const handleRetry = async (serverId, serverUrl, name) => {
// Remove failed connection
await fetch(`/agents/my-agent/session-id/disconnect`, {
method: "POST",
body: JSON.stringify({ serverId }),
});
// Retry connection
const response = await fetch(`/agents/my-agent/session-id/connect`, {
method: "POST",
body: JSON.stringify({ serverUrl, name }),
});
const { authUrl } = await response.json();
if (authUrl) window.open(authUrl, "_blank");
};
return (
<div>
{Object.entries(mcpState.servers).map(([id, server]) => (
<div key={id}>
<strong>{server.name}</strong>: {server.state}
{server.state === "failed" && (
<div>
{server.error && <p className="error">{server.error}</p>}
<button
onClick={() => handleRetry(id, server.server_url, server.name)}
>
Retry Connection
</button>
</div>
)}
</div>
))}
</div>
);
}import { useAgent } from "agents/react";
import { useState } from "react";
import type { MCPServersState } from "agents";
function App() {
const [mcpState, setMcpState] = useState<MCPServersState>({
prompts: [],
resources: [],
servers: {},
tools: [],
});
const agent = useAgent({
agent: "my-agent",
name: "session-id",
onMcpUpdate: setMcpState,
});
const handleRetry = async (
serverId: string,
serverUrl: string,
name: string,
) => {
// Remove failed connection
await fetch(`/agents/my-agent/session-id/disconnect`, {
method: "POST",
body: JSON.stringify({ serverId }),
});
// Retry connection
const response = await fetch(`/agents/my-agent/session-id/connect`, {
method: "POST",
body: JSON.stringify({ serverUrl, name }),
});
const { authUrl } = await response.json();
if (authUrl) window.open(authUrl, "_blank");
};
return (
<div>
{Object.entries(mcpState.servers).map(([id, server]) => (
<div key={id}>
<strong>{server.name}</strong>: {server.state}
{server.state === "failed" && (
<div>
{server.error && <p className="error">{server.error}</p>}
<button
onClick={() => handleRetry(id, server.server_url, server.name)}
>
Retry Connection
</button>
</div>
)}
</div>
))}
</div>
);
}よくある失敗理由:
- ユーザーがキャンセルした: 認可完了前に OAuth ウィンドウを閉じた
- 資格情報が無効: プロバイダーの資格情報が誤っていた
- 権限が拒否された: ユーザーに必要な権限がない
- セッション期限切れ: OAuth セッションがタイムアウトした
失敗した接続は、removeMcpServer(serverId) で削除するまで状態に残ります。エラーメッセージは XSS 攻撃を防ぐために自動でエスケープされるため、UI にそのまま表示して問題ありません。
この例は、Cloudflare Observability との一連の OAuth 連携です。ユーザーが接続し、ポップアップで認可すると、接続が利用可能になります。エラーは接続状態に自動保存され、UI に表示できます。
import { Agent, routeAgentRequest } from "agents";
export class MyAgent extends Agent {
onStart() {
this.mcp.configureOAuthCallback({
customHandler: () => {
// Close popup after OAuth completes (success or failure)
return new Response("<script>window.close();</script>", {
headers: { "content-type": "text/html" },
});
},
});
}
async onRequest(request) {
const url = new URL(request.url);
// Connect to MCP server
if (url.pathname.endsWith("/connect") && request.method === "POST") {
const { id, authUrl } = await this.addMcpServer(
"Cloudflare Observability",
"https://observability.mcp.cloudflare.com/mcp",
);
if (authUrl) {
return Response.json({
serverId: id,
authUrl: authUrl,
message: "Please authorize access",
});
}
return Response.json({ serverId: id, status: "connected" });
}
// Check connection status
if (url.pathname.endsWith("/status") && request.method === "GET") {
const mcpState = this.getMcpServers();
const connections = Object.entries(mcpState.servers).map(
([id, server]) => ({
serverId: id,
name: server.name,
state: server.state,
authUrl: server.auth_url,
}),
);
return Response.json(connections);
}
// Disconnect
if (url.pathname.endsWith("/disconnect") && request.method === "POST") {
const { serverId } = await request.json();
await this.removeMcpServer(serverId);
return Response.json({ message: "Disconnected" });
}
return new Response("Not found", { status: 404 });
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env, { cors: true })) ||
new Response("Not found", { status: 404 })
);
},
};import { Agent, routeAgentRequest } from "agents";
type Env = {
MyAgent: DurableObjectNamespace<MyAgent>;
};
export class MyAgent extends Agent<Env> {
onStart() {
this.mcp.configureOAuthCallback({
customHandler: () => {
// Close popup after OAuth completes (success or failure)
return new Response("<script>window.close();</script>", {
headers: { "content-type": "text/html" },
});
},
});
}
async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
// Connect to MCP server
if (url.pathname.endsWith("/connect") && request.method === "POST") {
const { id, authUrl } = await this.addMcpServer(
"Cloudflare Observability",
"https://observability.mcp.cloudflare.com/mcp",
);
if (authUrl) {
return Response.json({
serverId: id,
authUrl: authUrl,
message: "Please authorize access",
});
}
return Response.json({ serverId: id, status: "connected" });
}
// Check connection status
if (url.pathname.endsWith("/status") && request.method === "GET") {
const mcpState = this.getMcpServers();
const connections = Object.entries(mcpState.servers).map(
([id, server]) => ({
serverId: id,
name: server.name,
state: server.state,
authUrl: server.auth_url,
}),
);
return Response.json(connections);
}
// Disconnect
if (url.pathname.endsWith("/disconnect") && request.method === "POST") {
const { serverId } = (await request.json()) as { serverId: string };
await this.removeMcpServer(serverId);
return Response.json({ message: "Disconnected" });
}
return new Response("Not found", { status: 404 });
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env, { cors: true })) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;