Clover MCP Server

by ibraheem4
Verified
/** * OAuth V2 Implementation for Clover * * This file implements the High-trust app Auth code flow for Clover OAuth v2. * https://docs.clover.com/dev/docs/high-trust-app-auth-flow */ import axios from "axios"; import crypto from "crypto"; import express from "express"; import { exec } from "child_process"; import fs from "fs"; import path from "path"; import { logger } from "./logger.js"; // OAuth interfaces export interface OAuthTokenResponse { access_token: string; token_type: string; refresh_token?: string; merchant_id: string; expires_in: number; } export interface OAuthV2TokenResponse { access_token: string; refresh_token: string; merchant_id: string; access_token_expiry: number; // unix timestamp refresh_token_expiry: number; // unix timestamp } export interface OAuthConfig { clientId: string; clientSecret: string; baseUrl: string; redirectUrl: string; } // OAuth token storage let currentTokens: { accessToken: string | null; refreshToken: string | null; merchantId: string | null; accessTokenExpiry: number | null; refreshTokenExpiry: number | null; } = { accessToken: null, refreshToken: null, merchantId: null, accessTokenExpiry: null, refreshTokenExpiry: null, }; // Function to open a URL in the default browser function openBrowser(url: string) { let command; switch (process.platform) { case "darwin": // macOS command = `open "${url}"`; break; case "win32": // Windows command = `start "" "${url}"`; break; default: // Linux and others command = `xdg-open "${url}"`; break; } exec(command, (error) => { if (error) { logger.error(`Failed to open browser: ${error}`); logger.info(`Please manually open this URL in your browser: ${url}`); } }); } /** * OAuth v2 Client for Clover API */ export class OAuthV2Client { private config: OAuthConfig; private server: any = null; private state: string = ""; private promiseResolve: ((value: OAuthV2TokenResponse) => void) | null = null; private promiseReject: ((reason: any) => void) | null = null; constructor(config: OAuthConfig) { this.config = config; } /** * Get the current OAuth tokens */ getTokens() { return { accessToken: currentTokens.accessToken, refreshToken: currentTokens.refreshToken, merchantId: currentTokens.merchantId, accessTokenExpiry: currentTokens.accessTokenExpiry, refreshTokenExpiry: currentTokens.refreshTokenExpiry, }; } /** * Get the client ID */ getClientId(): string { return this.config.clientId; } /** * Set the OAuth tokens */ setTokens(tokens: OAuthV2TokenResponse) { currentTokens.accessToken = tokens.access_token; currentTokens.refreshToken = tokens.refresh_token; currentTokens.merchantId = tokens.merchant_id; currentTokens.accessTokenExpiry = tokens.access_token_expiry; currentTokens.refreshTokenExpiry = tokens.refresh_token_expiry; // Also update the .env file for persistence this.updateEnvFile(); } /** * Check if we have valid tokens */ hasValidTokens(): boolean { if (!currentTokens.accessToken || !currentTokens.merchantId) { return false; } // Check if the access token is expired if (currentTokens.accessTokenExpiry) { const now = Math.floor(Date.now() / 1000); if (now >= currentTokens.accessTokenExpiry) { return false; } } return true; } /** * Update the .env file with the current tokens */ private updateEnvFile() { try { const envPath = path.resolve(process.cwd(), ".env"); if (!fs.existsSync(envPath)) { logger.debug("No .env file found at", envPath); return; } let envContent = fs.readFileSync(envPath, "utf8"); // Update API key if (currentTokens.accessToken) { envContent = envContent.replace( /CLOVER_API_KEY=.*/, `CLOVER_API_KEY=${currentTokens.accessToken}` ); } // Update merchant ID if (currentTokens.merchantId) { envContent = envContent.replace( /CLOVER_MERCHANT_ID=.*/, `CLOVER_MERCHANT_ID=${currentTokens.merchantId}` ); } fs.writeFileSync(envPath, envContent); logger.debug("Successfully updated .env file with the new tokens"); } catch (error) { logger.error(`Error updating .env file: ${(error as Error).message}`); } } /** * Start the OAuth v2 flow */ async startOAuthFlow(port: number = 4000): Promise<OAuthV2TokenResponse> { return new Promise((resolve, reject) => { if (this.server) { reject(new Error("OAuth server is already running")); return; } this.promiseResolve = resolve; this.promiseReject = reject; // Generate a state parameter to prevent CSRF attacks this.state = crypto.randomBytes(16).toString("hex"); // Create Express app for OAuth callback const app = express(); // Set up the OAuth callback route app.get("/oauth-callback", async (req, res) => { const { code, state, merchant_id, client_id } = req.query; // Log all parameters for debugging logger.debug("OAuth callback parameters:", { code: code ? `${String(code).substring(0, 6)}...` : undefined, state, expectedState: this.state, merchant_id, client_id }); // More lenient state checking to handle empty state from Clover sandbox if (state !== this.state && this.state !== "") { logger.debug(`State mismatch: received "${state}", expected "${this.state}"`); logger.debug("Proceeding anyway since this might be from app installation"); // Continue anyway for sandbox debugging } if (!code) { res.status(400).send("Error: Missing authorization code"); this.rejectPromise(new Error("Missing authorization code")); return; } try { logger.debug("Received authorization code, exchanging for tokens"); // Store merchant ID from the callback URL in case the token response doesn't have it const callbackMerchantId = merchant_id as string; // Exchange the authorization code for tokens const tokenResponse = await this.exchangeCodeForTokens(code as string); if (tokenResponse) { // Ensure merchant ID is set - use the one from the callback if not in token response if (!tokenResponse.merchant_id && callbackMerchantId) { logger.debug(`No merchant ID in token response, using merchant ID from callback: ${callbackMerchantId}`); tokenResponse.merchant_id = callbackMerchantId; } // Ensure expiry dates are valid if (!tokenResponse.access_token_expiry || isNaN(tokenResponse.access_token_expiry)) { logger.debug("No valid access token expiry in response, setting default (1 hour)"); tokenResponse.access_token_expiry = Math.floor(Date.now() / 1000) + 3600; } if (!tokenResponse.refresh_token_expiry || isNaN(tokenResponse.refresh_token_expiry)) { logger.debug("No valid refresh token expiry in response, setting default (30 days)"); tokenResponse.refresh_token_expiry = Math.floor(Date.now() / 1000) + (30 * 86400); } logger.info("Successfully obtained OAuth tokens"); // Update our tokens this.setTokens(tokenResponse); // Send success response to the browser res.send(` <html> <head> <title>OAuth Tokens Generated</title> <style> body { font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; line-height: 1.6; } .success { color: #4CAF50; font-weight: bold; } </style> </head> <body> <h1>OAuth Tokens Generated Successfully!</h1> <p class="success">Your Clover OAuth tokens have been generated and saved.</p> <p>You can now close this window and return to your application.</p> </body> </html> `); // Resolve the promise with the token response this.resolvePromise(tokenResponse); // Shutdown the server after a delay setTimeout(() => { this.closeServer(); }, 3000); } else { logger.error("Error: No tokens in response"); res.status(500).send("Error: Failed to obtain tokens"); this.rejectPromise(new Error("No tokens in response")); } } catch (error) { logger.error(`Error exchanging code for tokens: ${error}`); res.status(500).send(`Error: ${(error as Error).message}`); this.rejectPromise(error as Error); } }); // Start the server this.server = app.listen(port, () => { logger.info(`OAuth callback server running at http://localhost:${port}`); // Generate the authorization URL const redirectUri = `http://localhost:${port}/oauth-callback`; // Try both v1 and v2 endpoints // First, try the v2 endpoint as documented const v2AuthUrl = `${this.config.baseUrl}/oauth/v2/authorize?client_id=${this.config.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${this.state}`; // Also provide v1 endpoint as fallback const v1AuthUrl = `${this.config.baseUrl}/oauth/authorize?client_id=${this.config.clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&state=${this.state}`; // Use v1 endpoint for now as it seems more compatible with sandbox const authUrl = v1AuthUrl; logger.debug("Authorization URLs:"); logger.debug(`- v1: ${v1AuthUrl}`); logger.debug(`- v2: ${v2AuthUrl}`); logger.debug(`- Using: ${authUrl}`); logger.info("-------------------------------------------------"); logger.info("INSTRUCTIONS:"); logger.info("-------------------------------------------------"); logger.info("1. A browser window will open to the Clover authorization page"); logger.info("2. Log in with your Clover account if prompted"); logger.info("3. Authorize the app to access your Clover account"); logger.info("4. You will be redirected back to this application"); logger.info("Opening browser to Clover authorization page..."); // Open the browser to the authorization URL openBrowser(authUrl); }); // Handle server errors this.server.on("error", (error: Error) => { logger.error(`OAuth server error: ${error}`); this.rejectPromise(error); }); }); } /** * Exchange an authorization code for tokens using OAuth v2 endpoints * With fallback to v1 if v2 fails */ private async exchangeCodeForTokens(code: string): Promise<OAuthV2TokenResponse> { try { logger.debug("Attempting to exchange auth code for tokens using v2 endpoint"); // Try OAuth v2 endpoint first try { const v2Response = await axios.post( `${this.config.baseUrl}/oauth/v2/token`, { client_id: this.config.clientId, client_secret: this.config.clientSecret, code: code }, { headers: { "Content-Type": "application/json", Accept: "application/json", }, } ); logger.debug("Successfully used OAuth v2 endpoint"); return v2Response.data; } catch (v2Error) { logger.debug("OAuth v2 endpoint failed, trying v1 endpoint"); // If v2 fails, try the v1 endpoint with form data approach const params = new URLSearchParams(); params.append("client_id", this.config.clientId); params.append("client_secret", this.config.clientSecret); params.append("code", code); const v1Response = await axios.post( `${this.config.baseUrl}/oauth/token`, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, } ); logger.debug("Successfully used OAuth v1 endpoint"); // Convert v1 response format to v2 format const v1Data = v1Response.data; const now = Math.floor(Date.now() / 1000); return { access_token: v1Data.access_token, refresh_token: v1Data.refresh_token || "", merchant_id: v1Data.merchant_id, access_token_expiry: now + (v1Data.expires_in || 3600), refresh_token_expiry: now + (30 * 86400) // 30 days default }; } } catch (error) { if (axios.isAxiosError(error)) { logger.error(`Token response error: ${JSON.stringify(error.response?.data)}`); throw new Error(`OAuth error: ${error.response?.data?.message || error.message}`); } throw error as Error; } } /** * Refresh the access token using the refresh token */ async refreshAccessToken(): Promise<OAuthV2TokenResponse> { if (!currentTokens.refreshToken) { throw new Error("No refresh token available"); } try { logger.debug("Refreshing access token"); // Try OAuth v2 refresh endpoint first try { const v2Response = await axios.post( `${this.config.baseUrl}/oauth/v2/refresh`, { client_id: this.config.clientId, client_secret: this.config.clientSecret, refresh_token: currentTokens.refreshToken }, { headers: { "Content-Type": "application/json", Accept: "application/json", }, } ); logger.debug("Successfully refreshed token with v2 endpoint"); // Update our tokens this.setTokens(v2Response.data); return v2Response.data; } catch (v2Error) { logger.debug("OAuth v2 refresh failed, trying v1 endpoint"); // If v2 fails, try the v1 endpoint // For v1, we'll use form-encoded data as that's what the API expects const params = new URLSearchParams(); params.append("client_id", this.config.clientId); params.append("client_secret", this.config.clientSecret); params.append("refresh_token", currentTokens.refreshToken); const v1Response = await axios.post( `${this.config.baseUrl}/oauth/token`, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, } ); logger.debug("Successfully refreshed token with v1 endpoint"); // Convert v1 response format to v2 format const v1Data = v1Response.data; const now = Math.floor(Date.now() / 1000); const transformedData = { access_token: v1Data.access_token, refresh_token: v1Data.refresh_token || currentTokens.refreshToken, // Keep old one if not provided merchant_id: v1Data.merchant_id || currentTokens.merchantId, // Keep old one if not provided access_token_expiry: now + (v1Data.expires_in || 3600), refresh_token_expiry: currentTokens.refreshTokenExpiry || now + (30 * 86400) // Keep old one or set 30 days }; // Update our tokens this.setTokens(transformedData); return transformedData; } } catch (error) { if (axios.isAxiosError(error)) { logger.error(`Refresh token response error: ${error.response?.status} ${error.response?.statusText}`); // If the refresh token fails, we'll need to reauthenticate throw new Error(`OAuth refresh error: Token expired or invalid. Please reauthenticate.`); } throw error as Error; } } /** * Check token status and refresh if needed */ async ensureValidToken(): Promise<string> { if (!this.hasValidTokens()) { // If we have a refresh token, try to refresh the access token if (currentTokens.refreshToken) { try { const tokenResponse = await this.refreshAccessToken(); return tokenResponse.access_token; } catch (error) { logger.error(`Error refreshing token: ${error}`); throw new Error("Unable to refresh token. Please re-authenticate."); } } else { throw new Error("No valid tokens available. Please authenticate using the OAuth flow."); } } return currentTokens.accessToken!; } /** * Close the OAuth server */ private closeServer() { if (this.server) { this.server.close(() => { logger.debug("OAuth server closed."); this.server = null; }); } } /** * Resolve the OAuth promise */ private resolvePromise(value: OAuthV2TokenResponse) { if (this.promiseResolve) { this.promiseResolve(value); this.promiseResolve = null; this.promiseReject = null; } } /** * Reject the OAuth promise */ private rejectPromise(reason: Error) { if (this.promiseReject) { this.promiseReject(reason); this.promiseReject = null; this.promiseResolve = null; } } }