Cloudflare MCP开发实战 - 创建支持OAuth的MCP服务器
Cloudflare MCP开发实战 - 创建支持OAuth的MCP服务器
在当今AI应用生态中,MCP(Model Context Protocol)服务器扮演着至关重要的角色,它允许AI客户端通过标准化的接口访问各种工具和服务。而添加用户认证机制,不仅可以保护您的服务资源,还能为不同用户提供个性化的体验。
赛博菩萨 Cloudflare 提供了一组开发工具,以及开发者手册,帮助 MCP 开发者更加轻松地开发并部署 MCP 服务器到云端。现在 Cloudflare 开发工具也对 MCP OAuth做了进一步支持。
本文将带您利用Cloudflare 开发工具,一步步实现一个支持GitHub OAuth认证的MCP服务器。
最终效果预览
完成本教程后,您将获得以下用户体验:
- 当您在Inspector尝试连接MCP服务器,系统会将其重定向到GitHub进行身份验证
- 完成授权后,会自动返回到MCP Inspector
- 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:
- 将Transport Type设置为SSE
- URL设置为
http://localhost:8787/sse - 点击
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应用
- 登录GitHub,进入Settings > Developer settings > OAuth Apps
- 点击"New OAuth App"
- 填写应用信息:
- Application name: MCP OAuth Demo
- Homepage URL: http://localhost:8787
- Authorization callback URL: http://localhost:8787/callback
创建完成后,您将获得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>
© ${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,配置:
- Transport Type: SSE
- URL: http://localhost:8787/sse
- 点击Connect
这次,您将被重定向到GitHub进行授权。授权完成后,您将返回到Inspector。
点击"List Tools",您会看到两个工具:
- add - 简单的加法工具
- userInfoOctokit - 获取当前登录用户身份信息的工具
点击userInfoOctokit并点击 Run Tool,您将看到自己的GitHub用户名显示在结果中。
总结与拓展
通过本教程,我们成功实现了一个支持GitHub OAuth认证的MCP服务器。这种认证机制使您的MCP服务能够:
- 验证用户身份,确保只有授权用户才能访问
- 获取用户信息,提供个性化服务
- 根据用户权限提供不同的工具和功能
您可以基于此框架进一步拓展:
- 集成其他OAuth提供商(如Google、Microsoft等)
- 实现自定义权限控制,为不同用户提供不同工具
- 将MCP服务器与您现有的用户系统集成
希望本教程对您有所帮助!如有任何问题,欢迎在评论区留言。
参考资源
相关阅读
什么MCP工具值得装?Firecrawl - 代码编辑器变身强大的智能化网络爬虫平台
什么MCP工具值得装?Tavily篇 - Cursor中的AI搜索引擎
什么MCP工具值得装?Perplexity - 最佳AI搜索引擎轻松接入Cursor
MCP协议规范重大更新近在眼前?开发者翘首期盼的Streamable HTTP要来了
【必读】a16z 发布 MCP 生态全景图,哪些产品值得你关注?