返回博客2025年3月27日

Cloudflare MCP开发实战 - 创建支持OAuth的MCP服务器

MCPCloudflareOAuthGitHub开发教程

Cloudflare MCP开发实战 - 创建支持OAuth的MCP服务器

在当今AI应用生态中,MCP(Model Context Protocol)服务器扮演着至关重要的角色,它允许AI客户端通过标准化的接口访问各种工具和服务。而添加用户认证机制,不仅可以保护您的服务资源,还能为不同用户提供个性化的体验。

赛博菩萨 Cloudflare 提供了一组开发工具,以及开发者手册,帮助 MCP 开发者更加轻松地开发并部署 MCP 服务器到云端。现在 Cloudflare 开发工具也对 MCP OAuth做了进一步支持。

本文将带您利用Cloudflare 开发工具,一步步实现一个支持GitHub OAuth认证的MCP服务器。

最终效果预览

完成本教程后,您将获得以下用户体验:

  1. 当您在Inspector尝试连接MCP服务器,系统会将其重定向到GitHub进行身份验证
  2. 完成授权后,会自动返回到MCP Inspector
  3. MCP 服务器可以获取用户的GitHub信息(这也是未来个性化的工具和服务的基础)

开发环境准备

在开始之前,请确保您已安装:

  • Node.js
  • npm, yarn或pnpm
  • Git

第一部分:创建基础MCP服务器

首先,我们需要创建一个基础的Cloudflare MCP服务器项目:

# 使用npx创建项目
npx create cloudflare@latest my-mcp-server-github --template=cloudflare/ai/demos/remote-mcp-server

在创建过程中,系统会询问:

  • 是否使用Git进行版本控制?选择 yes
  • 是否需要部署?选择 no(我们先在本地开发环境运行)

项目创建完成后,进入项目目录并启动开发服务器:

cd my-mcp-server-github
npm run dev

此时,您的MCP服务器已在本地运行,默认端口为8787。

使用MCP Inspector进行测试

MCP Inspector是一个用于测试MCP服务器的工具。运行以下命令启动Inspector:

$ npx @modelcontextprotocol/inspector

现在,访问Inspector http://localhost:517

  1. 将Transport Type设置为SSE
  2. URL设置为http://localhost:8787/sse
  3. 点击 Connect

此时,您会看到一个基本的授权页面,这是Cloudflare MCP服务器模板自带的简单认证机制。点击Approve后,您将返回Inspector界面。

您可以测试默认提供的add工具,例如设置a=100,b=200,执行后应返回300。

第二部分:添加GitHub OAuth支持

现在,我们将为MCP服务器添加GitHub OAuth认证支持。根据我的实测,按照 https://developers.cloudflare.com/agents/guides/remote-mcp-server/#add-authentication 的步骤创建的 MCP 服务器,无法正常运行并提供服务。因此,大家务必根据我的以下步骤操作。

1. 在GitHub创建OAuth应用

  1. 登录GitHub,进入Settings > Developer settings > OAuth Apps
  2. 点击"New OAuth App"
  3. 填写应用信息:

创建完成后,您将获得Client ID,点击 Generate a new client secret 获取Client Secret。

2. 配置环境变量

在项目根目录创建.dev.vars文件,添加以下内容:

GITHUB_CLIENT_ID=您的GitHub Client ID
GITHUB_CLIENT_SECRET=您的GitHub Client Secret

3. 修改MCP服务器代码

修改index.ts

首先,我们需要更新src/index.ts文件,导入所需的依赖并配置OAuth。完整代码如下:

import app from "./app";
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import OAuthProvider from "@cloudflare/workers-oauth-provider";
import { GitHubHandler } from "./github-handler";
import { Octokit } from "octokit";

// Context from the auth process, encrypted & stored in the auth token
// and provided to the DurableMCP as this.props
type Props = {
	login: string;
	name: string;
	email: string;
	accessToken: string;
};

const ALLOWED_USERNAMES = new Set([
	// Add GitHub usernames of users who should have access to the image generation tool
	// For example: 'yourusername', 'coworkerusername'
]);
export class MyMCP extends McpAgent {
	server = new McpServer({
		name: "Demo",
		version: "1.0.0",
	});

	async init() {
		this.server.tool("add", { a: z.number(), b: z.number() }, async ({ a, b }) => ({
			content: [{ type: "text", text: String(a + b) }],
		}));

        // Use the upstream access token to facilitate tools
		this.server.tool(
			"userInfoOctokit",
			"Get user info from GitHub, via Octokit",
			{},
			async () => {
				const octokit = new Octokit({ auth: this.props.accessToken });
                const user = await octokit.rest.users.getAuthenticated();
				return {
					content: [
						{
							type: "text",
							text: user.data.login || "Unknown",
						},
					],
				};
			},
		);
	}
}

// Export the OAuth handler as the default
export default new OAuthProvider({
	apiRoute: "/sse",
	// TODO: fix these types
	// @ts-ignore
	apiHandler: MyMCP.mount("/sse"),
	// @ts-ignore
	defaultHandler: GitHubHandler,
	authorizeEndpoint: "/authorize",
	tokenEndpoint: "/token",
	clientRegistrationEndpoint: "/register",
});

创建github-handler.ts

src 目录下创建 github-handler.ts 文件:

import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider";
import { Hono } from "hono";
import { Octokit } from "octokit";
import { fetchUpstreamAuthToken, getUpstreamAuthorizeUrl } from "./utils";

const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>();

/**
 * OAuth Authorization Endpoint
 *
 * This route initiates the GitHub OAuth flow when a user wants to log in.
 * It creates a random state parameter to prevent CSRF attacks and stores the
 * original OAuth request information in KV storage for later retrieval.
 * Then it redirects the user to GitHub's authorization page with the appropriate
 * parameters so the user can authenticate and grant permissions.
 */
app.get("/authorize", async (c) => {
	const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
	if (!oauthReqInfo.clientId) {
		return c.text("Invalid request", 400);
	}

	return Response.redirect(
		getUpstreamAuthorizeUrl({
			upstream_url: "https://github.com/login/oauth/authorize",
			scope: "read:user",
			client_id: c.env.GITHUB_CLIENT_ID,
			redirect_uri: new URL("/callback", c.req.url).href,
			state: btoa(JSON.stringify(oauthReqInfo)),
		}),
	);
});

/**
 * OAuth Callback Endpoint
 *
 * This route handles the callback from GitHub after user authentication.
 * It exchanges the temporary code for an access token, then stores some
 * user metadata & the auth token as part of the 'props' on the token passed
 * down to the client. It ends by redirecting the client back to _its_ callback URL
 */
app.get("/callback", async (c) => {
	// Get the oathReqInfo out of KV
	const oauthReqInfo = JSON.parse(atob(c.req.query("state") as string)) as AuthRequest;
	if (!oauthReqInfo.clientId) {
		return c.text("Invalid state", 400);
	}

	// Exchange the code for an access token
	const [accessToken, errResponse] = await fetchUpstreamAuthToken({
		upstream_url: "https://github.com/login/oauth/access_token",
		client_id: c.env.GITHUB_CLIENT_ID,
		client_secret: c.env.GITHUB_CLIENT_SECRET,
		code: c.req.query("code"),
		redirect_uri: new URL("/callback", c.req.url).href,
	});
	if (errResponse) return errResponse;

	// Fetch the user info from GitHub
	const user = await new Octokit({ auth: accessToken }).rest.users.getAuthenticated();
	const { login, name, email } = user.data;

	// Return back to the MCP client a new token
	const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
		request: oauthReqInfo,
		userId: login,
		metadata: {
			label: name,
		},
		scope: oauthReqInfo.scope,
		// This will be available on this.props inside MyMCP
		props: {
			login,
			name,
			email,
			accessToken,
		} as Props,
	});

	return Response.redirect(redirectTo);
});

export const GitHubHandler = app;

创建utils.ts

src 目录下更新 utils.ts 文件:

// Helper to generate the layout
import { html, raw } from "hono/html";
import type { HtmlEscapedString } from "hono/utils/html";
import { marked } from "marked";
import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
import { env } from "cloudflare:workers";

// This file mainly exists as a dumping ground for uninteresting html and CSS
// to remove clutter and noise from the auth logic. You likely do not need
// anything from this file.

export const layout = (content: HtmlEscapedString | string, title: string) => html`
	<!DOCTYPE html>
	<html lang="en">
		<head>
			<meta charset="UTF-8" />
			<meta
				name="viewport"
				content="width=device-width, initial-scale=1.0"
			/>
			<title>${title}</title>
			<script src="https://cdn.tailwindcss.com"></script>
			<script>
				tailwind.config = {
					theme: {
						extend: {
							colors: {
								primary: "#3498db",
								secondary: "#2ecc71",
								accent: "#f39c12",
							},
							fontFamily: {
								sans: ["Inter", "system-ui", "sans-serif"],
								heading: ["Roboto", "system-ui", "sans-serif"],
							},
						},
					},
				};
			</script>
			<style>
				@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap");

				/* Custom styling for markdown content */
				.markdown h1 {
					font-size: 2.25rem;
					font-weight: 700;
					font-family: "Roboto", system-ui, sans-serif;
					color: #1a202c;
					margin-bottom: 1rem;
					line-height: 1.2;
				}

				.markdown h2 {
					font-size: 1.5rem;
					font-weight: 600;
					font-family: "Roboto", system-ui, sans-serif;
					color: #2d3748;
					margin-top: 1.5rem;
					margin-bottom: 0.75rem;
					line-height: 1.3;
				}

				.markdown h3 {
					font-size: 1.25rem;
					font-weight: 600;
					font-family: "Roboto", system-ui, sans-serif;
					color: #2d3748;
					margin-top: 1.25rem;
					margin-bottom: 0.5rem;
				}

				.markdown p {
					font-size: 1.125rem;
					color: #4a5568;
					margin-bottom: 1rem;
					line-height: 1.6;
				}

				.markdown a {
					color: #3498db;
					font-weight: 500;
					text-decoration: none;
				}

				.markdown a:hover {
					text-decoration: underline;
				}

				.markdown blockquote {
					border-left: 4px solid #f39c12;
					padding-left: 1rem;
					padding-top: 0.75rem;
					padding-bottom: 0.75rem;
					margin-top: 1.5rem;
					margin-bottom: 1.5rem;
					background-color: #fffbeb;
					font-style: italic;
				}

				.markdown blockquote p {
					margin-bottom: 0.25rem;
				}

				.markdown ul,
				.markdown ol {
					margin-top: 1rem;
					margin-bottom: 1rem;
					margin-left: 1.5rem;
					font-size: 1.125rem;
					color: #4a5568;
				}

				.markdown li {
					margin-bottom: 0.5rem;
				}

				.markdown ul li {
					list-style-type: disc;
				}

				.markdown ol li {
					list-style-type: decimal;
				}

				.markdown pre {
					background-color: #f7fafc;
					padding: 1rem;
					border-radius: 0.375rem;
					margin-top: 1rem;
					margin-bottom: 1rem;
					overflow-x: auto;
				}

				.markdown code {
					font-family: monospace;
					font-size: 0.875rem;
					background-color: #f7fafc;
					padding: 0.125rem 0.25rem;
					border-radius: 0.25rem;
				}

				.markdown pre code {
					background-color: transparent;
					padding: 0;
				}
			</style>
		</head>
		<body
			class="bg-gray-50 text-gray-800 font-sans leading-relaxed flex flex-col min-h-screen"
		>
			<header class="bg-white shadow-sm mb-8">
				<div
					class="container mx-auto px-4 py-4 flex justify-between items-center"
				>
					<a
						href="/"
						class="text-xl font-heading font-bold text-primary hover:text-primary/80 transition-colors"
						>MCP Remote Auth Demo</a
					>
				</div>
			</header>
			<main class="container mx-auto px-4 pb-12 flex-grow">
				${content}
			</main>
			<footer class="bg-gray-100 py-6 mt-12">
				<div class="container mx-auto px-4 text-center text-gray-600">
					<p>
						&copy; ${new Date().getFullYear()} MCP Remote Auth Demo.
						All rights reserved.
					</p>
				</div>
			</footer>
		</body>
	</html>
`;

export const homeContent = async (req: Request): Promise<HtmlEscapedString> => {
	// We have the README symlinked into the static directory, so we can fetch it
	// and render it into HTML
	const origin = new URL(req.url).origin;
	const res = await env.ASSETS.fetch(`${origin}/README.md`);
	const markdown = await res.text();
	const content = await marked(markdown);
	return html`
		<div class="max-w-4xl mx-auto markdown">${raw(content)}</div>
	`;
};

export const renderLoggedInAuthorizeScreen = async (
	oauthScopes: { name: string; description: string }[],
	oauthReqInfo: AuthRequest,
) => {
	return html`
		<div class="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
			<h1 class="text-2xl font-heading font-bold mb-6 text-gray-900">
				Authorization Request
			</h1>

			<div class="mb-8">
				<h2 class="text-lg font-semibold mb-3 text-gray-800">
					MCP Remote Auth Demo would like permission to:
				</h2>
				<ul class="space-y-2">
					${oauthScopes.map(
						(scope) => html`
							<li class="flex items-start">
								<span
									class="inline-block mr-2 mt-1 text-secondary"
									>✓</span
								>
								<div>
									<p class="font-medium">${scope.name}</p>
									<p class="text-gray-600 text-sm">
										${scope.description}
									</p>
								</div>
							</li>
						`,
					)}
				</ul>
			</div>
			<form action="/approve" method="POST" class="space-y-4">
				<input
					type="hidden"
					name="oauthReqInfo"
					value="${JSON.stringify(oauthReqInfo)}"
				/>
				<input type="hidden" name="email" value="user@example.com" />
				<button
					type="submit"
					name="action"
					value="approve"
					class="w-full py-3 px-4 bg-secondary text-white rounded-md font-medium hover:bg-secondary/90 transition-colors"
				>
					Approve
				</button>
				<button
					type="submit"
					name="action"
					value="reject"
					class="w-full py-3 px-4 border border-gray-300 text-gray-700 rounded-md font-medium hover:bg-gray-50 transition-colors"
				>
					Reject
				</button>
			</form>
		</div>
	`;
};

export const renderLoggedOutAuthorizeScreen = async (
	oauthScopes: { name: string; description: string }[],
	oauthReqInfo: AuthRequest,
) => {
	return html`
		<div class="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
			<h1 class="text-2xl font-heading font-bold mb-6 text-gray-900">
				Authorization Request
			</h1>

			<div class="mb-8">
				<h2 class="text-lg font-semibold mb-3 text-gray-800">
					MCP Remote Auth Demo would like permission to:
				</h2>
				<ul class="space-y-2">
					${oauthScopes.map(
						(scope) => html`
							<li class="flex items-start">
								<span
									class="inline-block mr-2 mt-1 text-secondary"
									>✓</span
								>
								<div>
									<p class="font-medium">${scope.name}</p>
									<p class="text-gray-600 text-sm">
										${scope.description}
									</p>
								</div>
							</li>
						`,
					)}
				</ul>
			</div>
			<form action="/approve" method="POST" class="space-y-4">
				<input
					type="hidden"
					name="oauthReqInfo"
					value="${JSON.stringify(oauthReqInfo)}"
				/>
				<div class="space-y-4">
					<div>
						<label
							for="email"
							class="block text-sm font-medium text-gray-700 mb-1"
							>Email</label
						>
						<input
							type="email"
							id="email"
							name="email"
							required
							class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
						/>
					</div>
					<div>
						<label
							for="password"
							class="block text-sm font-medium text-gray-700 mb-1"
							>Password</label
						>
						<input
							type="password"
							id="password"
							name="password"
							required
							class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
						/>
					</div>
				</div>
				<button
					type="submit"
					name="action"
					value="login_approve"
					class="w-full py-3 px-4 bg-primary text-white rounded-md font-medium hover:bg-primary/90 transition-colors"
				>
					Log in and Approve
				</button>
				<button
					type="submit"
					name="action"
					value="reject"
					class="w-full py-3 px-4 border border-gray-300 text-gray-700 rounded-md font-medium hover:bg-gray-50 transition-colors"
				>
					Reject
				</button>
			</form>
		</div>
	`;
};

export const renderApproveContent = async (
	message: string,
	status: string,
	redirectUrl: string,
) => {
	return html`
		<div
			class="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md text-center"
		>
			<div class="mb-4">
				<span
					class="inline-block p-3 ${
						status === "success"
							? "bg-green-100 text-green-800"
							: "bg-red-100 text-red-800"
					} rounded-full"
				>
					${status === "success" ? "✓" : "✗"}
				</span>
			</div>
			<h1 class="text-2xl font-heading font-bold mb-4 text-gray-900">
				${message}
			</h1>
			<p class="mb-8 text-gray-600">
				You will be redirected back to the application shortly.
			</p>
			<a
				href="/"
				class="inline-block py-2 px-4 bg-primary text-white rounded-md font-medium hover:bg-primary/90 transition-colors"
			>
				Return to Home
			</a>
			${raw(`
				<script>
					setTimeout(() => {
						window.location.href = "${redirectUrl}";
					}, 2000);
				</script>
			`)}
		</div>
	`;
};

export const renderAuthorizationApprovedContent = async (redirectUrl: string) => {
	return renderApproveContent("Authorization approved!", "success", redirectUrl);
};

export const renderAuthorizationRejectedContent = async (redirectUrl: string) => {
	return renderApproveContent("Authorization rejected.", "error", redirectUrl);
};

export const parseApproveFormBody = async (body: {
	[x: string]: string | File;
}) => {
	const action = body.action as string;
	const email = body.email as string;
	const password = body.password as string;
	let oauthReqInfo: AuthRequest | null = null;
	try {
		oauthReqInfo = JSON.parse(body.oauthReqInfo as string) as AuthRequest;
	} catch (e) {
		oauthReqInfo = null;
	}

	return { action, oauthReqInfo, email, password };
};

/**
 * Constructs an authorization URL for an upstream service.
 *
 * @param {Object} options
 * @param {string} options.upstream_url - The base URL of the upstream service.
 * @param {string} options.client_id - The client ID of the application.
 * @param {string} options.redirect_uri - The redirect URI of the application.
 * @param {string} [options.state] - The state parameter.
 *
 * @returns {string} The authorization URL.
 */
export function getUpstreamAuthorizeUrl({
	upstream_url,
	client_id,
	scope,
	redirect_uri,
	state,
}: {
	upstream_url: string;
	client_id: string;
	scope: string;
	redirect_uri: string;
	state?: string;
}) {
	const upstream = new URL(upstream_url);
	upstream.searchParams.set("client_id", client_id);
	upstream.searchParams.set("redirect_uri", redirect_uri);
	upstream.searchParams.set("scope", scope);
	if (state) upstream.searchParams.set("state", state);
	upstream.searchParams.set("response_type", "code");
	return upstream.href;
}

/**
 * Fetches an authorization token from an upstream service.
 *
 * @param {Object} options
 * @param {string} options.client_id - The client ID of the application.
 * @param {string} options.client_secret - The client secret of the application.
 * @param {string} options.code - The authorization code.
 * @param {string} options.redirect_uri - The redirect URI of the application.
 * @param {string} options.upstream_url - The token endpoint URL of the upstream service.
 *
 * @returns {Promise<[string, null] | [null, Response]>} A promise that resolves to an array containing the access token or an error response.
 */
export async function fetchUpstreamAuthToken({
	client_id,
	client_secret,
	code,
	redirect_uri,
	upstream_url,
}: {
	code: string | undefined;
	upstream_url: string;
	client_secret: string;
	redirect_uri: string;
	client_id: string;
}): Promise<[string, null] | [null, Response]> {
	if (!code) {
		return [null, new Response("Missing code", { status: 400 })];
	}

	const resp = await fetch(upstream_url, {
		method: "POST",
		headers: {
			"Content-Type": "application/x-www-form-urlencoded",
		},
		body: new URLSearchParams({ client_id, client_secret, code, redirect_uri }).toString(),
	});
	if (!resp.ok) {
		console.log(await resp.text());
		return [null, new Response("Failed to fetch access token", { status: 500 })];
	}
	const body = await resp.formData();
	const accessToken = body.get("access_token") as string;
	if (!accessToken) {
		return [null, new Response("Missing access token", { status: 400 })];
	}
	return [accessToken, null];
}

4. 安装依赖

我们需要安装Octokit库来与GitHub API交互:

npm install octokit

第三部分:测试OAuth认证流程

现在,重启MCP服务器:

npm run dev

然后重启,并再次打开MCP Inspector,配置:

这次,您将被重定向到GitHub进行授权。授权完成后,您将返回到Inspector。

点击"List Tools",您会看到两个工具:

  1. add - 简单的加法工具
  2. userInfoOctokit - 获取当前登录用户身份信息的工具

点击userInfoOctokit并点击 Run Tool,您将看到自己的GitHub用户名显示在结果中。

总结与拓展

通过本教程,我们成功实现了一个支持GitHub OAuth认证的MCP服务器。这种认证机制使您的MCP服务能够:

  1. 验证用户身份,确保只有授权用户才能访问
  2. 获取用户信息,提供个性化服务
  3. 根据用户权限提供不同的工具和功能

您可以基于此框架进一步拓展:

  • 集成其他OAuth提供商(如Google、Microsoft等)
  • 实现自定义权限控制,为不同用户提供不同工具
  • 将MCP服务器与您现有的用户系统集成

希望本教程对您有所帮助!如有任何问题,欢迎在评论区留言。

参考资源


相关阅读

什么是MCP?你奶奶都能看明白的解释

【深度思考】MCP究竟是什么?

什么MCP工具值得装?Firecrawl - 代码编辑器变身强大的智能化网络爬虫平台

什么MCP工具值得装?Slack - AI时代高效团队协作

什么MCP工具值得装?Tavily篇 - Cursor中的AI搜索引擎

什么MCP工具值得装?Perplexity - 最佳AI搜索引擎轻松接入Cursor

MCP协议规范重大更新近在眼前?开发者翘首期盼的Streamable HTTP要来了

【必读】a16z 发布 MCP 生态全景图,哪些产品值得你关注?

【开发者收藏夹必备】MCP服务器聚合平台汇总

MCP终局?Zapier MCP连接万物


准备开始了吗?

先简单说明目标,我会给出最合适的沟通方式。