MCP Server Example with PRPs

This commit is contained in:
Cole Medin
2025-07-12 11:48:38 -05:00
parent 73d08d2236
commit 8445f05b67
42 changed files with 18567 additions and 0 deletions

View 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 };

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- 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];
}

View 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;
}
}
}

View 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.";
}

View 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.
}

View 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",
});

View 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",
});

View 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);
}

View 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 };