mirror of
https://github.com/coleam00/context-engineering-intro.git
synced 2025-12-29 16:14:56 +00:00
MCP Server Example with PRPs
This commit is contained in:
119
use-cases/mcp-server/src/auth/github-handler.ts
Normal file
119
use-cases/mcp-server/src/auth/github-handler.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// import { env } from "cloudflare:workers";
|
||||
import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
|
||||
import { Hono } from "hono";
|
||||
import { Octokit } from "octokit";
|
||||
import type { Props, ExtendedEnv } from "../types";
|
||||
import {
|
||||
clientIdAlreadyApproved,
|
||||
parseRedirectApproval,
|
||||
renderApprovalDialog,
|
||||
fetchUpstreamAuthToken,
|
||||
getUpstreamAuthorizeUrl,
|
||||
} from "./oauth-utils";
|
||||
const app = new Hono<{ Bindings: ExtendedEnv }>();
|
||||
|
||||
app.get("/authorize", async (c) => {
|
||||
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
|
||||
const { clientId } = oauthReqInfo;
|
||||
if (!clientId) {
|
||||
return c.text("Invalid request", 400);
|
||||
}
|
||||
|
||||
if (
|
||||
await clientIdAlreadyApproved(c.req.raw, oauthReqInfo.clientId, (c.env as any).COOKIE_ENCRYPTION_KEY)
|
||||
) {
|
||||
return redirectToGithub(c.req.raw, oauthReqInfo, c.env, {});
|
||||
}
|
||||
|
||||
return renderApprovalDialog(c.req.raw, {
|
||||
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
|
||||
server: {
|
||||
description: "This is a demo MCP Remote Server using GitHub for authentication.",
|
||||
logo: "https://avatars.githubusercontent.com/u/314135?s=200&v=4",
|
||||
name: "Cloudflare GitHub MCP Server", // optional
|
||||
},
|
||||
state: { oauthReqInfo }, // arbitrary data that flows through the form submission below
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/authorize", async (c) => {
|
||||
// Validates form submission, extracts state, and generates Set-Cookie headers to skip approval dialog next time
|
||||
const { state, headers } = await parseRedirectApproval(c.req.raw, (c.env as any).COOKIE_ENCRYPTION_KEY);
|
||||
if (!state.oauthReqInfo) {
|
||||
return c.text("Invalid request", 400);
|
||||
}
|
||||
|
||||
return redirectToGithub(c.req.raw, state.oauthReqInfo, c.env, headers);
|
||||
});
|
||||
|
||||
async function redirectToGithub(
|
||||
request: Request,
|
||||
oauthReqInfo: AuthRequest,
|
||||
env: Env,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...headers,
|
||||
location: getUpstreamAuthorizeUrl({
|
||||
client_id: (env as any).GITHUB_CLIENT_ID,
|
||||
redirect_uri: new URL("/callback", request.url).href,
|
||||
scope: "read:user",
|
||||
state: btoa(JSON.stringify(oauthReqInfo)),
|
||||
upstream_url: "https://github.com/login/oauth/authorize",
|
||||
}),
|
||||
},
|
||||
status: 302,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
client_id: (c.env as any).GITHUB_CLIENT_ID,
|
||||
client_secret: (c.env as any).GITHUB_CLIENT_SECRET,
|
||||
code: c.req.query("code"),
|
||||
redirect_uri: new URL("/callback", c.req.url).href,
|
||||
upstream_url: "https://github.com/login/oauth/access_token",
|
||||
});
|
||||
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({
|
||||
metadata: {
|
||||
label: name,
|
||||
},
|
||||
// This will be available on this.props inside MyMCP
|
||||
props: {
|
||||
accessToken,
|
||||
email,
|
||||
login,
|
||||
name,
|
||||
} as Props,
|
||||
request: oauthReqInfo,
|
||||
scope: oauthReqInfo.scope,
|
||||
userId: login,
|
||||
});
|
||||
|
||||
return Response.redirect(redirectTo);
|
||||
});
|
||||
|
||||
export { app as GitHubHandler };
|
||||
662
use-cases/mcp-server/src/auth/oauth-utils.ts
Normal file
662
use-cases/mcp-server/src/auth/oauth-utils.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
// OAuth utilities for cookie-based approval and upstream OAuth flows
|
||||
|
||||
import type {
|
||||
AuthRequest,
|
||||
ClientInfo,
|
||||
ApprovalDialogOptions,
|
||||
ParsedApprovalResult,
|
||||
UpstreamAuthorizeParams,
|
||||
UpstreamTokenParams
|
||||
} from "../types";
|
||||
|
||||
const COOKIE_NAME = "mcp-approved-clients";
|
||||
const ONE_YEAR_IN_SECONDS = 31536000;
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/**
|
||||
* Encodes arbitrary data to a URL-safe base64 string.
|
||||
* @param data - The data to encode (will be stringified).
|
||||
* @returns A URL-safe base64 encoded string.
|
||||
*/
|
||||
function _encodeState(data: any): string {
|
||||
try {
|
||||
const jsonString = JSON.stringify(data);
|
||||
// Use btoa for simplicity, assuming Worker environment supports it well enough
|
||||
// For complex binary data, a Buffer/Uint8Array approach might be better
|
||||
return btoa(jsonString);
|
||||
} catch (e) {
|
||||
console.error("Error encoding state:", e);
|
||||
throw new Error("Could not encode state");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a URL-safe base64 string back to its original data.
|
||||
* @param encoded - The URL-safe base64 encoded string.
|
||||
* @returns The original data.
|
||||
*/
|
||||
function decodeState<T = any>(encoded: string): T {
|
||||
try {
|
||||
const jsonString = atob(encoded);
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
console.error("Error decoding state:", e);
|
||||
throw new Error("Could not decode state");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a secret key string for HMAC-SHA256 signing.
|
||||
* @param secret - The raw secret key string.
|
||||
* @returns A promise resolving to the CryptoKey object.
|
||||
*/
|
||||
async function importKey(secret: string): Promise<CryptoKey> {
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"COOKIE_SECRET is not defined. A secret key is required for signing cookies.",
|
||||
);
|
||||
}
|
||||
const enc = new TextEncoder();
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(secret),
|
||||
{ hash: "SHA-256", name: "HMAC" },
|
||||
false, // not extractable
|
||||
["sign", "verify"], // key usages
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs data using HMAC-SHA256.
|
||||
* @param key - The CryptoKey for signing.
|
||||
* @param data - The string data to sign.
|
||||
* @returns A promise resolving to the signature as a hex string.
|
||||
*/
|
||||
async function signData(key: CryptoKey, data: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const signatureBuffer = await crypto.subtle.sign("HMAC", key, enc.encode(data));
|
||||
// Convert ArrayBuffer to hex string
|
||||
return Array.from(new Uint8Array(signatureBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an HMAC-SHA256 signature.
|
||||
* @param key - The CryptoKey for verification.
|
||||
* @param signatureHex - The signature to verify (hex string).
|
||||
* @param data - The original data that was signed.
|
||||
* @returns A promise resolving to true if the signature is valid, false otherwise.
|
||||
*/
|
||||
async function verifySignature(
|
||||
key: CryptoKey,
|
||||
signatureHex: string,
|
||||
data: string,
|
||||
): Promise<boolean> {
|
||||
const enc = new TextEncoder();
|
||||
try {
|
||||
// Convert hex signature back to ArrayBuffer
|
||||
const signatureBytes = new Uint8Array(
|
||||
signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)),
|
||||
);
|
||||
return await crypto.subtle.verify("HMAC", key, signatureBytes.buffer, enc.encode(data));
|
||||
} catch (e) {
|
||||
// Handle errors during hex parsing or verification
|
||||
console.error("Error verifying signature:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the signed cookie and verifies its integrity.
|
||||
* @param cookieHeader - The value of the Cookie header from the request.
|
||||
* @param secret - The secret key used for signing.
|
||||
* @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
|
||||
*/
|
||||
async function getApprovedClientsFromCookie(
|
||||
cookieHeader: string | null,
|
||||
secret: string,
|
||||
): Promise<string[] | null> {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
||||
const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`));
|
||||
|
||||
if (!targetCookie) return null;
|
||||
|
||||
const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1);
|
||||
const parts = cookieValue.split(".");
|
||||
|
||||
if (parts.length !== 2) {
|
||||
console.warn("Invalid cookie format received.");
|
||||
return null; // Invalid format
|
||||
}
|
||||
|
||||
const [signatureHex, base64Payload] = parts;
|
||||
const payload = atob(base64Payload); // Assuming payload is base64 encoded JSON string
|
||||
|
||||
const key = await importKey(secret);
|
||||
const isValid = await verifySignature(key, signatureHex, payload);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn("Cookie signature verification failed.");
|
||||
return null; // Signature invalid
|
||||
}
|
||||
|
||||
try {
|
||||
const approvedClients = JSON.parse(payload);
|
||||
if (!Array.isArray(approvedClients)) {
|
||||
console.warn("Cookie payload is not an array.");
|
||||
return null; // Payload isn't an array
|
||||
}
|
||||
// Ensure all elements are strings
|
||||
if (!approvedClients.every((item) => typeof item === "string")) {
|
||||
console.warn("Cookie payload contains non-string elements.");
|
||||
return null;
|
||||
}
|
||||
return approvedClients as string[];
|
||||
} catch (e) {
|
||||
console.error("Error parsing cookie payload:", e);
|
||||
return null; // JSON parsing failed
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exported Functions ---
|
||||
|
||||
/**
|
||||
* Checks if a given client ID has already been approved by the user,
|
||||
* based on a signed cookie.
|
||||
*
|
||||
* @param request - The incoming Request object to read cookies from.
|
||||
* @param clientId - The OAuth client ID to check approval for.
|
||||
* @param cookieSecret - The secret key used to sign/verify the approval cookie.
|
||||
* @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
|
||||
*/
|
||||
export async function clientIdAlreadyApproved(
|
||||
request: Request,
|
||||
clientId: string,
|
||||
cookieSecret: string,
|
||||
): Promise<boolean> {
|
||||
if (!clientId) return false;
|
||||
const cookieHeader = request.headers.get("Cookie");
|
||||
const approvedClients = await getApprovedClientsFromCookie(cookieHeader, cookieSecret);
|
||||
|
||||
return approvedClients?.includes(clientId) ?? false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Renders an approval dialog for OAuth authorization
|
||||
* The dialog displays information about the client and server
|
||||
* and includes a form to submit approval
|
||||
*
|
||||
* @param request - The HTTP request
|
||||
* @param options - Configuration for the approval dialog
|
||||
* @returns A Response containing the HTML approval dialog
|
||||
*/
|
||||
export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
|
||||
const { client, server, state } = options;
|
||||
|
||||
// Encode state for form submission
|
||||
const encodedState = btoa(JSON.stringify(state));
|
||||
|
||||
// Sanitize any untrusted content
|
||||
const serverName = sanitizeHtml(server.name);
|
||||
const clientName = client?.clientName ? sanitizeHtml(client.clientName) : "Unknown MCP Client";
|
||||
const serverDescription = server.description ? sanitizeHtml(server.description) : "";
|
||||
|
||||
// Safe URLs
|
||||
const logoUrl = server.logo ? sanitizeHtml(server.logo) : "";
|
||||
const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : "";
|
||||
const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : "";
|
||||
const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : "";
|
||||
|
||||
// Client contacts
|
||||
const contacts =
|
||||
client?.contacts && client.contacts.length > 0
|
||||
? sanitizeHtml(client.contacts.join(", "))
|
||||
: "";
|
||||
|
||||
// Get redirect URIs
|
||||
const redirectUris =
|
||||
client?.redirectUris && client.redirectUris.length > 0
|
||||
? client.redirectUris.map((uri) => sanitizeHtml(uri))
|
||||
: [];
|
||||
|
||||
// Generate HTML for the approval dialog
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${clientName} | Authorization Request</title>
|
||||
<style>
|
||||
/* Modern, responsive styling with system fonts */
|
||||
:root {
|
||||
--primary-color: #0070f3;
|
||||
--error-color: #f44336;
|
||||
--border-color: #e5e7eb;
|
||||
--text-color: #333;
|
||||
--background-color: #fff;
|
||||
--card-shadow: 0 8px 36px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol";
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: #f9fafb;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.precard {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--background-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 1rem;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.client-detail {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-value a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-value.small {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.external-link-icon {
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.25rem;
|
||||
vertical-align: super;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
margin: 1rem auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.client-detail {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
min-width: unset;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="precard">
|
||||
<div class="header">
|
||||
${logoUrl ? `<img src="${logoUrl}" alt="${serverName} Logo" class="logo">` : ""}
|
||||
<h1 class="title"><strong>${serverName}</strong></h1>
|
||||
</div>
|
||||
|
||||
${serverDescription ? `<p class="description">${serverDescription}</p>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
<h2 class="alert"><strong>${clientName || "A new MCP Client"}</strong> is requesting access</h1>
|
||||
|
||||
<div class="client-info">
|
||||
<div class="client-detail">
|
||||
<div class="detail-label">Name:</div>
|
||||
<div class="detail-value">
|
||||
${clientName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
clientUri
|
||||
? `
|
||||
<div class="client-detail">
|
||||
<div class="detail-label">Website:</div>
|
||||
<div class="detail-value small">
|
||||
<a href="${clientUri}" target="_blank" rel="noopener noreferrer">
|
||||
${clientUri}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
policyUri
|
||||
? `
|
||||
<div class="client-detail">
|
||||
<div class="detail-label">Privacy Policy:</div>
|
||||
<div class="detail-value">
|
||||
<a href="${policyUri}" target="_blank" rel="noopener noreferrer">
|
||||
${policyUri}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
tosUri
|
||||
? `
|
||||
<div class="client-detail">
|
||||
<div class="detail-label">Terms of Service:</div>
|
||||
<div class="detail-value">
|
||||
<a href="${tosUri}" target="_blank" rel="noopener noreferrer">
|
||||
${tosUri}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
redirectUris.length > 0
|
||||
? `
|
||||
<div class="client-detail">
|
||||
<div class="detail-label">Redirect URIs:</div>
|
||||
<div class="detail-value small">
|
||||
${redirectUris.map((uri) => `<div>${uri}</div>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
contacts
|
||||
? `
|
||||
<div class="client-detail">
|
||||
<div class="detail-label">Contact:</div>
|
||||
<div class="detail-value">${contacts}</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<p>This MCP Client is requesting to be authorized on ${serverName}. If you approve, you will be redirected to complete authentication.</p>
|
||||
|
||||
<form method="post" action="${new URL(request.url).pathname}">
|
||||
<input type="hidden" name="state" value="${encodedState}">
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
|
||||
<button type="submit" class="button button-primary">Approve</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return new Response(htmlContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses the form submission from the approval dialog, extracts the state,
|
||||
* and generates Set-Cookie headers to mark the client as approved.
|
||||
*
|
||||
* @param request - The incoming POST Request object containing the form data.
|
||||
* @param cookieSecret - The secret key used to sign the approval cookie.
|
||||
* @returns A promise resolving to an object containing the parsed state and necessary headers.
|
||||
* @throws If the request method is not POST, form data is invalid, or state is missing.
|
||||
*/
|
||||
export async function parseRedirectApproval(
|
||||
request: Request,
|
||||
cookieSecret: string,
|
||||
): Promise<ParsedApprovalResult> {
|
||||
if (request.method !== "POST") {
|
||||
throw new Error("Invalid request method. Expected POST.");
|
||||
}
|
||||
|
||||
let state: any;
|
||||
let clientId: string | undefined;
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const encodedState = formData.get("state");
|
||||
|
||||
if (typeof encodedState !== "string" || !encodedState) {
|
||||
throw new Error("Missing or invalid 'state' in form data.");
|
||||
}
|
||||
|
||||
state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState); // Decode the state
|
||||
clientId = state?.oauthReqInfo?.clientId; // Extract clientId from within the state
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error("Could not extract clientId from state object.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing form submission:", e);
|
||||
// Rethrow or handle as appropriate, maybe return a specific error response
|
||||
throw new Error(
|
||||
`Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get existing approved clients
|
||||
const cookieHeader = request.headers.get("Cookie");
|
||||
const existingApprovedClients =
|
||||
(await getApprovedClientsFromCookie(cookieHeader, cookieSecret)) || [];
|
||||
|
||||
// Add the newly approved client ID (avoid duplicates)
|
||||
const updatedApprovedClients = Array.from(new Set([...existingApprovedClients, clientId]));
|
||||
|
||||
// Sign the updated list
|
||||
const payload = JSON.stringify(updatedApprovedClients);
|
||||
const key = await importKey(cookieSecret);
|
||||
const signature = await signData(key, payload);
|
||||
const newCookieValue = `${signature}.${btoa(payload)}`; // signature.base64(payload)
|
||||
|
||||
// Generate Set-Cookie header
|
||||
const headers: Record<string, string> = {
|
||||
"Set-Cookie": `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`,
|
||||
};
|
||||
|
||||
return { headers, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks
|
||||
* @param unsafe - The unsafe string that might contain HTML
|
||||
* @returns A safe string with HTML special characters escaped
|
||||
*/
|
||||
function sanitizeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- OAuth Helper Functions ---
|
||||
|
||||
/**
|
||||
* Constructs an authorization URL for an upstream service.
|
||||
*
|
||||
* @param {UpstreamAuthorizeParams} options - The parameters for constructing the URL
|
||||
* @returns {string} The authorization URL.
|
||||
*/
|
||||
export function getUpstreamAuthorizeUrl({
|
||||
upstream_url,
|
||||
client_id,
|
||||
scope,
|
||||
redirect_uri,
|
||||
state,
|
||||
}: UpstreamAuthorizeParams): 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 {UpstreamTokenParams} options - The parameters for the token exchange
|
||||
* @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,
|
||||
}: UpstreamTokenParams): Promise<[string, null] | [null, Response]> {
|
||||
if (!code) {
|
||||
return [null, new Response("Missing code", { status: 400 })];
|
||||
}
|
||||
|
||||
const resp = await fetch(upstream_url, {
|
||||
body: new URLSearchParams({ client_id, client_secret, code, redirect_uri }).toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
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];
|
||||
}
|
||||
37
use-cases/mcp-server/src/database/connection.ts
Normal file
37
use-cases/mcp-server/src/database/connection.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import postgres from "postgres";
|
||||
|
||||
let dbInstance: postgres.Sql | null = null;
|
||||
|
||||
/**
|
||||
* Get database connection singleton
|
||||
* Following the pattern from BASIC-DB-MCP.md but adapted for PostgreSQL with connection pooling
|
||||
*/
|
||||
export function getDb(databaseUrl: string): postgres.Sql {
|
||||
if (!dbInstance) {
|
||||
dbInstance = postgres(databaseUrl, {
|
||||
// Connection pool settings for Cloudflare Workers
|
||||
max: 5, // Maximum 5 connections to fit within Workers' limit of 6 concurrent connections
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
// Enable prepared statements for better performance
|
||||
prepare: true,
|
||||
});
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection pool
|
||||
* Call this when the Durable Object is shutting down
|
||||
*/
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (dbInstance) {
|
||||
try {
|
||||
await dbInstance.end();
|
||||
} catch (error) {
|
||||
console.error('Error closing database connection:', error);
|
||||
} finally {
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
72
use-cases/mcp-server/src/database/security.ts
Normal file
72
use-cases/mcp-server/src/database/security.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { SqlValidationResult } from "../types";
|
||||
|
||||
/**
|
||||
* SQL injection protection: Basic SQL keyword validation
|
||||
* This is a simple check - in production you should use parameterized queries
|
||||
*/
|
||||
export function validateSqlQuery(sql: string): SqlValidationResult {
|
||||
const trimmedSql = sql.trim().toLowerCase();
|
||||
|
||||
// Check for empty queries
|
||||
if (!trimmedSql) {
|
||||
return { isValid: false, error: "SQL query cannot be empty" };
|
||||
}
|
||||
|
||||
// Check for obviously dangerous patterns
|
||||
const dangerousPatterns = [
|
||||
/;\s*drop\s+/i,
|
||||
/^drop\s+/i, // DROP at start of query
|
||||
/;\s*delete\s+.*\s+where\s+1\s*=\s*1/i,
|
||||
/;\s*update\s+.*\s+set\s+.*\s+where\s+1\s*=\s*1/i,
|
||||
/;\s*truncate\s+/i,
|
||||
/^truncate\s+/i, // TRUNCATE at start of query
|
||||
/;\s*alter\s+/i,
|
||||
/^alter\s+/i, // ALTER at start of query
|
||||
/;\s*create\s+/i,
|
||||
/;\s*grant\s+/i,
|
||||
/;\s*revoke\s+/i,
|
||||
/xp_cmdshell/i,
|
||||
/sp_executesql/i,
|
||||
];
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (pattern.test(sql)) {
|
||||
return { isValid: false, error: "Query contains potentially dangerous SQL patterns" };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a SQL query is a write operation
|
||||
*/
|
||||
export function isWriteOperation(sql: string): boolean {
|
||||
const trimmedSql = sql.trim().toLowerCase();
|
||||
const writeKeywords = [
|
||||
'insert', 'update', 'delete', 'create', 'drop', 'alter',
|
||||
'truncate', 'grant', 'revoke', 'commit', 'rollback'
|
||||
];
|
||||
|
||||
return writeKeywords.some(keyword => trimmedSql.startsWith(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format database error for user-friendly display
|
||||
*/
|
||||
export function formatDatabaseError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
// Hide sensitive connection details
|
||||
if (error.message.includes('password')) {
|
||||
return "Database authentication failed. Please check your credentials.";
|
||||
}
|
||||
if (error.message.includes('timeout')) {
|
||||
return "Database connection timed out. Please try again.";
|
||||
}
|
||||
if (error.message.includes('connection') || error.message.includes('connect')) {
|
||||
return "Unable to connect to database. Please check your connection string.";
|
||||
}
|
||||
return `Database error: ${error.message}`;
|
||||
}
|
||||
return "An unknown database error occurred.";
|
||||
}
|
||||
27
use-cases/mcp-server/src/database/utils.ts
Normal file
27
use-cases/mcp-server/src/database/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import postgres from "postgres";
|
||||
import { getDb } from "./connection";
|
||||
|
||||
/**
|
||||
* Execute a database operation with proper connection management
|
||||
* Following the pattern from BASIC-DB-MCP.md but adapted for PostgreSQL
|
||||
*/
|
||||
export async function withDatabase<T>(
|
||||
databaseUrl: string,
|
||||
operation: (db: postgres.Sql) => Promise<T>
|
||||
): Promise<T> {
|
||||
const db = getDb(databaseUrl);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await operation(db);
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`Database operation completed successfully in ${duration}ms`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`Database operation failed after ${duration}ms:`, error);
|
||||
// Re-throw the error so it can be caught by Sentry in the calling code
|
||||
throw error;
|
||||
}
|
||||
// Note: With PostgreSQL connection pooling, we don't close individual connections
|
||||
// They're returned to the pool automatically. The pool is closed when the Durable Object shuts down.
|
||||
}
|
||||
49
use-cases/mcp-server/src/index.ts
Normal file
49
use-cases/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import OAuthProvider from "@cloudflare/workers-oauth-provider";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { McpAgent } from "agents/mcp";
|
||||
import { Props } from "./types";
|
||||
import { GitHubHandler } from "./auth/github-handler";
|
||||
import { closeDb } from "./database/connection";
|
||||
import { registerAllTools } from "./tools/register-tools";
|
||||
|
||||
export class MyMCP extends McpAgent<Env, Record<string, never>, Props> {
|
||||
server = new McpServer({
|
||||
name: "PostgreSQL Database MCP Server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
/**
|
||||
* Cleanup database connections when Durable Object is shutting down
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
try {
|
||||
await closeDb();
|
||||
console.log('Database connections closed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during database cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Objects alarm handler - used for cleanup
|
||||
*/
|
||||
async alarm(): Promise<void> {
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Register all tools based on user permissions
|
||||
registerAllTools(this.server, this.env, this.props);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OAuthProvider({
|
||||
apiHandlers: {
|
||||
'/sse': MyMCP.serveSSE('/sse') as any,
|
||||
'/mcp': MyMCP.serve('/mcp') as any,
|
||||
},
|
||||
authorizeEndpoint: "/authorize",
|
||||
clientRegistrationEndpoint: "/register",
|
||||
defaultHandler: GitHubHandler as any,
|
||||
tokenEndpoint: "/token",
|
||||
});
|
||||
68
use-cases/mcp-server/src/index_sentry.ts
Normal file
68
use-cases/mcp-server/src/index_sentry.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as Sentry from "@sentry/cloudflare";
|
||||
import OAuthProvider from "@cloudflare/workers-oauth-provider";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { McpAgent } from "agents/mcp";
|
||||
import { Props } from "./types";
|
||||
import { GitHubHandler } from "./auth/github-handler";
|
||||
import { closeDb } from "./database/connection";
|
||||
//@ts-ignore
|
||||
import { registerDatabaseToolsWithSentry } from "./tools/database-tools-sentry";
|
||||
|
||||
// Sentry configuration helper
|
||||
function getSentryConfig(env: Env) {
|
||||
return {
|
||||
// You can disable Sentry by setting SENTRY_DSN to a falsey-value
|
||||
dsn: (env as any).SENTRY_DSN,
|
||||
// A sample rate of 1.0 means "capture all traces"
|
||||
tracesSampleRate: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export class MyMCP extends McpAgent<Env, Record<string, never>, Props> {
|
||||
server = new McpServer({
|
||||
name: "PostgreSQL Database MCP Server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
/**
|
||||
* Cleanup database connections when Durable Object is shutting down
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
try {
|
||||
await closeDb();
|
||||
console.log('Database connections closed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during database cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Objects alarm handler - used for cleanup
|
||||
*/
|
||||
async alarm(): Promise<void> {
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Initialize Sentry
|
||||
const sentryConfig = getSentryConfig(this.env);
|
||||
if (sentryConfig.dsn) {
|
||||
// @ts-ignore - Sentry.init exists but types may not be complete
|
||||
Sentry.init(sentryConfig);
|
||||
}
|
||||
|
||||
// Register all tools with Sentry instrumentation
|
||||
registerDatabaseToolsWithSentry(this.server, this.env, this.props);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OAuthProvider({
|
||||
apiHandlers: {
|
||||
'/sse': MyMCP.serveSSE('/sse') as any,
|
||||
'/mcp': MyMCP.serve('/mcp') as any,
|
||||
},
|
||||
authorizeEndpoint: "/authorize",
|
||||
clientRegistrationEndpoint: "/register",
|
||||
defaultHandler: GitHubHandler as any,
|
||||
tokenEndpoint: "/token",
|
||||
});
|
||||
14
use-cases/mcp-server/src/tools/register-tools.ts
Normal file
14
use-cases/mcp-server/src/tools/register-tools.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { Props } from "../types";
|
||||
import { registerDatabaseTools } from "../../examples/database-tools";
|
||||
|
||||
/**
|
||||
* Register all MCP tools based on user permissions
|
||||
*/
|
||||
export function registerAllTools(server: McpServer, env: Env, props: Props) {
|
||||
// Register database tools
|
||||
registerDatabaseTools(server, env, props);
|
||||
|
||||
// Future tools can be registered here
|
||||
// registerOtherTools(server, env, props);
|
||||
}
|
||||
126
use-cases/mcp-server/src/types.ts
Normal file
126
use-cases/mcp-server/src/types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { z } from "zod";
|
||||
import type { AuthRequest, OAuthHelpers, ClientInfo } from "@cloudflare/workers-oauth-provider";
|
||||
|
||||
// User context passed through OAuth
|
||||
export type Props = {
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
// Extended environment with OAuth provider
|
||||
export type ExtendedEnv = Env & { OAUTH_PROVIDER: OAuthHelpers };
|
||||
|
||||
// OAuth URL construction parameters
|
||||
export interface UpstreamAuthorizeParams {
|
||||
upstream_url: string;
|
||||
client_id: string;
|
||||
scope: string;
|
||||
redirect_uri: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
// OAuth token exchange parameters
|
||||
export interface UpstreamTokenParams {
|
||||
code: string | undefined;
|
||||
upstream_url: string;
|
||||
client_secret: string;
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
// Approval dialog configuration
|
||||
export interface ApprovalDialogOptions {
|
||||
client: ClientInfo | null;
|
||||
server: {
|
||||
name: string;
|
||||
logo?: string;
|
||||
description?: string;
|
||||
};
|
||||
state: Record<string, any>;
|
||||
cookieName?: string;
|
||||
cookieSecret?: string | Uint8Array;
|
||||
cookieDomain?: string;
|
||||
cookiePath?: string;
|
||||
cookieMaxAge?: number;
|
||||
}
|
||||
|
||||
// Result of parsing approval form
|
||||
export interface ParsedApprovalResult {
|
||||
state: any;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
// MCP tool schemas using Zod
|
||||
export const ListTablesSchema = {};
|
||||
|
||||
export const QueryDatabaseSchema = {
|
||||
sql: z
|
||||
.string()
|
||||
.min(1, "SQL query cannot be empty")
|
||||
.describe("SQL query to execute (SELECT queries only)"),
|
||||
};
|
||||
|
||||
export const ExecuteDatabaseSchema = {
|
||||
sql: z
|
||||
.string()
|
||||
.min(1, "SQL command cannot be empty")
|
||||
.describe("SQL command to execute (INSERT, UPDATE, DELETE, CREATE, etc.)"),
|
||||
};
|
||||
|
||||
// MCP response types
|
||||
export interface McpTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface McpResponse {
|
||||
content: McpTextContent[];
|
||||
}
|
||||
|
||||
// Standard response creators
|
||||
export function createSuccessResponse(message: string, data?: any): McpResponse {
|
||||
let text = `**Success**\n\n${message}`;
|
||||
if (data !== undefined) {
|
||||
text += `\n\n**Result:**\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``;
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function createErrorResponse(message: string, details?: any): McpResponse {
|
||||
let text = `**Error**\n\n${message}`;
|
||||
if (details !== undefined) {
|
||||
text += `\n\n**Details:**\n\`\`\`json\n${JSON.stringify(details, null, 2)}\n\`\`\``;
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text,
|
||||
isError: true,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Database operation result type
|
||||
export interface DatabaseOperationResult<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// SQL validation result
|
||||
export interface SqlValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Re-export external types that are used throughout
|
||||
export type { AuthRequest, OAuthHelpers, ClientInfo };
|
||||
Reference in New Issue
Block a user