Skip to main content
Glama

FreshBooks MCP Server

by roboulos
xano-handler.ts18.5 kB
import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider' import { Hono } from 'hono' import { fetchXanoAuthToken, fetchXanoUserInfo, Props } from './utils' import { clientIdAlreadyApproved, parseRedirectApproval, renderApprovalDialog } from './workers-oauth-utils' import { Env } from './index' const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>() // Handler for /authorize GET - initial entry for OAuth flow app.get('/authorize', async (c) => { // Parse the OAuth request from the client (exactly like GitHub example) const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw) // Extract and validate client ID (exactly like GitHub example) const { clientId } = oauthReqInfo if (!clientId) { return c.text('Invalid request', 400) } // Check if this client is already approved (exactly like GitHub example) // Note: You would need to set a COOKIE_ENCRYPTION_KEY environment variable if (c.env.COOKIE_ENCRYPTION_KEY && await clientIdAlreadyApproved(c.req.raw, oauthReqInfo.clientId, c.env.COOKIE_ENCRYPTION_KEY)) { return redirectToXanoLogin(c.req.raw, oauthReqInfo, c.env.XANO_BASE_URL) } // Show approval dialog (exactly like GitHub example) return renderApprovalDialog(c.req.raw, { client: await c.env.OAUTH_PROVIDER.lookupClient(clientId), server: { name: "Snappy MCP Server", logo: "https://xnwv-v1z6-dvnr.n7c.xano.io/vault/ze3RfzZ2/XmHFEalO-FuKgcZQCCuxtdniBvk/U3GjRA../snappy+logo.png", description: 'Connect your Xano instance to Claude with Snappy MCP - the easiest way to use Xano tools.', }, state: { oauthReqInfo }, // Pass OAuth request info through approval process }) }) // Handler for /authorize POST - processes approval form submission app.post('/authorize', async (c) => { // Parse the approval form (exactly like GitHub example) try { // Process the form and get state and cookie headers const { state, headers } = await parseRedirectApproval(c.req.raw, c.env.COOKIE_ENCRYPTION_KEY || 'default-key') // Validate the state data if (!state.oauthReqInfo) { return c.text('Invalid request', 400) } // Redirect to Xano login with the OAuth request info return redirectToXanoLogin(c.req.raw, state.oauthReqInfo, c.env.XANO_BASE_URL, headers) } catch (error) { console.error("Error processing approval form:", error); return c.text('Error processing approval', 500); } }) // Function to redirect to Xano login page (equivalent to GitHub's redirectToGithub function) async function redirectToXanoLogin(request: Request, oauthReqInfo: AuthRequest, baseUrl: string, headers: Record<string, string> = {}) { // Create the login page URL const loginUrl = new URL('/login', request.url); // Pass the OAuth request info in state parameter const state = btoa(JSON.stringify(oauthReqInfo)); loginUrl.searchParams.set('state', state); // Create the redirect response return new Response(null, { status: 302, headers: { ...headers, location: loginUrl.href, }, }) } // Xano login page app.get('/login', async (c) => { try { // Get the state parameter from the URL const state = c.req.query('state'); if (!state) { return c.text('Missing state parameter', 400); } // Render the login form HTML return new Response(renderLoginForm(state), { headers: { 'Content-Type': 'text/html; charset=utf-8', }, }); } catch (error) { console.error("Error in login page:", error); return c.text('Error loading login page', 500); } }) // Process login form submission app.post('/login', async (c) => { try { // Parse the form data const formData = await c.req.parseBody(); const email = formData.email as string; const password = formData.password as string; const state = formData.state as string; // Validate form data if (!email || !password || !state) { return c.text('Missing required form fields', 400); } // Authenticate with Xano const [token, errorResponse] = await fetchXanoAuthToken({ base_url: c.env.XANO_BASE_URL, email, password, }); // Handle authentication error if (errorResponse) { return new Response(renderLoginForm(state, 'Invalid email or password. Please try again.'), { headers: { 'Content-Type': 'text/html; charset=utf-8', }, }); } // Redirect to callback with the token and state return Response.redirect(`${new URL('/callback', c.req.url).href}?token=${encodeURIComponent(token!)}&state=${encodeURIComponent(state)}`); } catch (error) { console.error("Error processing login form:", error); return c.text('Error processing login form', 500); } }) // Direct token authentication handler app.get('/token-auth', async (c) => { try { // Get token and state from query params const token = c.req.query('token'); const state = c.req.query('state'); if (!token || !state) { return c.text('Missing token or state parameter', 400); } // Redirect to callback with the token and state return Response.redirect(`${new URL('/callback', c.req.url).href}?token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`); } catch (error) { console.error("Error in token auth:", error); return c.text('Error processing token authentication', 500); } }) /** * OAuth Callback Endpoint * * This route handles the callback after Xano authentication. * It fetches user data, and completes the OAuth flow exactly like the GitHub example. */ app.get("/callback", async (c) => { try { // Get token and state from query parameters const token = c.req.query("token"); const state = c.req.query("state"); if (!token || !state) { return c.text("Missing token or state parameter", 400); } // Parse the original OAuth request info from state const oauthReqInfo = JSON.parse(atob(state)) as AuthRequest; if (!oauthReqInfo.clientId) { return c.text("Invalid state", 400); } // Fetch user info from Xano const [userData, errResponse] = await fetchXanoUserInfo({ base_url: c.env.XANO_BASE_URL, token, }); if (errResponse) { return errResponse; } // Extract user data const userId = userData.id || token.substring(0, 10); const name = userData.name || userData.email || 'Xano User'; const email = userData.email; // Extract API key from auth/me response const apiKey = userData.api_key || null; // Don't fall back to token! console.log("User data from /auth/me:", { hasApiKey: !!userData.api_key, apiKeyPrefix: userData.api_key ? userData.api_key.substring(0, 20) + '...' : null, userIdFromResponse: userData.id, email: userData.email, name: userData.name }); console.log("Props being set in completeAuthorization:", { userId: userId, email: email, apiKeyPrefix: apiKey ? apiKey.substring(0, 20) + '...' : 'NO_API_KEY' }); // Store the token explicitly in KV storage for our refresh mechanism await c.env.OAUTH_KV.put( `xano_auth_token:${userId}`, JSON.stringify({ authToken: token, apiKey: apiKey, userId: userId, name: name, email: email, authenticated: true, lastUpdated: new Date().toISOString() }), { expirationTtl: 604800 } // 7 days ); console.log(`Explicitly stored auth token in KV for user ${userId}`); // Calculate token TTL with environment variable control const configuredTTL = parseInt(c.env.OAUTH_TOKEN_TTL || "86400"); // 24 hours default const tokenTTL = Math.max(configuredTTL, 3600); // Minimum 1 hour console.log(`Setting OAuth token TTL to ${tokenTTL} seconds (${tokenTTL / 3600} hours)`); // Complete authorization exactly like GitHub example const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ request: oauthReqInfo, userId, metadata: { label: name, }, scope: oauthReqInfo.scope, // This will be available as this.props inside MyMCP props: { accessToken: token, name, email, apiKey: apiKey, // Use the API key from auth/me response freshbooksKey: userData.freshbooks_key || null, // FreshBooks key from user's Xano account userId: userId, // Explicitly set userId for hello tool authenticated: true, } as Props, accessTokenTTL: tokenTTL, // Enable automatic OAuth refresh when token expires }); return Response.redirect(redirectTo); } catch (error) { console.error("Error in callback endpoint:", error); return c.text("Error completing authentication", 500); } }); // Health check endpoint app.get('/health', (c) => { return c.json({ status: 'ok', server: 'Snappy MCP Server' }); }); // Function to render the login form function renderLoginForm(state: string, errorMessage?: string): string { return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Snappy MCP Authentication</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f9f9f9; margin: 0; padding: 20px; display: flex; justify-content: center; align-items: center; min-height: 100vh; } .container { background-color: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); padding: 35px; width: 100%; max-width: 440px; border: 1px solid #eaeaea; } h1 { color: #333; margin-top: 0; text-align: center; font-size: 24px; margin-bottom: 20px; } .form-group { margin-bottom: 20px; } label { display: block; margin-bottom: 8px; font-weight: 500; color: #444; } input[type="email"], input[type="password"], input[type="text"] { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px; box-sizing: border-box; } button { background-color: #FEDA31; /* Snappy yellow */ color: black; border: 1px solid black; border-radius: 4px; padding: 12px 20px; font-size: 16px; width: 100%; cursor: pointer; transition: all 0.2s; font-weight: 500; } button:hover { background-color: #FFE55C; transform: translateY(-1px); } .error { color: #dc3545; margin: 0 0 20px; padding: 10px; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; } .logo { text-align: center; margin-bottom: 20px; } .logo img { max-width: 220px; margin-bottom: 20px; } .tagline { text-align: center; margin-bottom: 20px; color: #666; font-size: 16px; line-height: 1.4; } .xano-integration { display: flex; align-items: center; justify-content: center; margin: 20px 0; padding: 15px; background-color: #f5f5f5; border-radius: 8px; } .xano-integration img { height: 30px; margin-right: 10px; } .xano-integration span { font-size: 14px; color: #555; } .security-info { margin-bottom: 20px; } .security-box { display: flex; align-items: flex-start; background-color: #f5f5f5; border-radius: 8px; padding: 15px; margin: 20px 0; } .security-icon { flex-shrink: 0; margin-right: 15px; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; } .security-icon svg { color: #333; } .security-info p { font-size: 14px; color: #555; margin: 0; line-height: 1.5; text-align: left; } .divider { margin: 30px 0; border-top: 1px solid #eee; position: relative; } .divider span { position: absolute; top: -10px; background: white; padding: 0 10px; left: 50%; transform: translateX(-50%); color: #777; } .alt-method { text-align: center; margin-top: 20px; font-size: 14px; color: #777; } .alt-method a { color: #007bff; text-decoration: none; } </style> </head> <body> <div class="container"> <div class="logo"> <a href="https://mcp.snappy.ai" target="_blank"> <img src="https://xnwv-v1z6-dvnr.n7c.xano.io/vault/ze3RfzZ2/XmHFEalO-FuKgcZQCCuxtdniBvk/U3GjRA../snappy+logo.png" alt="Snappy Logo"> </a> </div> <div class="tagline"> Sign in with your Snappy account to securely access your Xano tools </div> <div class="security-info"> <div class="security-box"> <div class="security-icon"> <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> </svg> </div> <p>Snappy keeps your credentials and API keys secure. Your data stays protected while AI interacts with your apps.</p> </div> </div> <form action="/login" method="POST"> <input type="hidden" name="state" value="${state}"> ${errorMessage ? `<div class="error">${errorMessage}</div>` : ''} <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" required autocomplete="email"> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" id="password" name="password" required autocomplete="current-password"> </div> <button type="submit">Sign In</button> </form> <!-- API Token option removed as requested --> </div> <!-- JavaScript removed as token option was removed --> </body> </html>`; } // Class to handle fetch requests using the Hono app export class XanoHandlerClass { async fetch(request: Request, env: any) { return app.fetch(request, env); } } // Export handler instance export const XanoHandler = new XanoHandlerClass(); // Export the OAuth callback handler for testing export async function handleOAuthCallback(context: { request: Request; env: any }) { try { // Get token and state from query parameters const url = new URL(context.request.url); const token = url.searchParams.get("token"); const state = url.searchParams.get("state"); if (!token || !state) { return new Response("Missing token or state parameter", { status: 400 }); } // Parse the original OAuth request info from state const oauthReqInfo = JSON.parse(atob(state)) as AuthRequest; if (!oauthReqInfo.clientId) { return new Response("Invalid state", { status: 400 }); } // Fetch user info from Xano const [userData, errResponse] = await fetchXanoUserInfo({ base_url: context.env.XANO_BASE_URL, token, }); if (errResponse) { return errResponse; } // Extract user data const userId = userData.id || token.substring(0, 10); const name = userData.name || userData.email || 'Xano User'; const email = userData.email; // Extract API key from auth/me response const apiKey = userData.api_key || token; // Store the token explicitly in KV storage for our refresh mechanism await context.env.OAUTH_KV.put( `xano_auth_token:${userId}`, JSON.stringify({ authToken: token, apiKey: apiKey, userId: userId, name: name, email: email, authenticated: true, lastRefreshed: new Date().toISOString() }), { expirationTtl: 604800 } // 7 days ); // Calculate token TTL with environment variable control const configuredTTL = parseInt(context.env.OAUTH_TOKEN_TTL || "86400"); // 24 hours default const tokenTTL = Math.max(configuredTTL, 3600); // Minimum 1 hour // Complete authorization const { redirectTo } = await context.env.OAUTH_PROVIDER.completeAuthorization({ request: oauthReqInfo, userId, metadata: { label: name, }, scope: oauthReqInfo.scope, props: { accessToken: token, name, email, apiKey: apiKey, userId: userId, authenticated: true, } as Props, accessTokenTTL: tokenTTL, }); return Response.redirect(redirectTo); } catch (error) { console.error("Error in OAuth callback:", error); return new Response("Error completing authentication", { status: 500 }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/roboulos/freshbooks-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server