dbx-mcp-server
Version:
A Model Context Protocol server for Dropbox integration with AI-powered PDF analysis, multi-directory indexing, and parallel processing
248 lines • 11.6 kB
JavaScript
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { randomBytes, createHash } from 'crypto';
import dotenv from 'dotenv';
import { encryptData, decryptData, TokenRefreshError, TOKEN_REFRESH_CONFIG } from './security-utils.js';
import { config } from './config.js';
dotenv.config();
const TOKEN_STORE_PATH = process.env.TOKEN_STORE_PATH || path.join(process.cwd(), '.tokens.json');
// Use config values which handle encrypted secrets
const validatedAppKey = config.dropbox.appKey;
const validatedAppSecret = config.dropbox.appSecret;
const validatedRedirectUri = config.dropbox.redirectUri;
// Skip validation during setup
const isSetup = process.argv[1]?.endsWith('setup.js');
if (!isSetup && (!validatedAppKey || !validatedAppSecret || !validatedRedirectUri)) {
throw new McpError(ErrorCode.InvalidParams, 'Missing required configuration. Please ensure API credentials are properly set.');
}
let tokenData = process.env.DROPBOX_ACCESS_TOKEN ? {
accessToken: process.env.DROPBOX_ACCESS_TOKEN,
refreshToken: '',
expiresAt: Date.now() + (4 * 60 * 60 * 1000), // 4 hours from now
scope: ['files.content.read', 'files.content.write']
} : loadTokenData();
// Error messages map for better error handling
const ERROR_MESSAGES = {
TOKEN_EXPIRED: 'Access token has expired. Attempting to refresh...',
REFRESH_FAILED: 'Failed to refresh access token after multiple attempts.',
INVALID_GRANT: 'The refresh token is invalid or has been revoked. Please re-authenticate.',
NETWORK_ERROR: 'Network error occurred while refreshing token. Will retry...',
RATE_LIMIT: 'Rate limit exceeded. Please try again later.',
SERVER_ERROR: 'API server error occurred. Will retry...'
};
function generatePKCE() {
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
function loadTokenData() {
try {
// Use logger instead of console.error to avoid polluting the JSON structure
if (process.env.NODE_ENV === 'development') {
console.error('Loading tokens from:', TOKEN_STORE_PATH);
}
if (fs.existsSync(TOKEN_STORE_PATH)) {
if (process.env.NODE_ENV === 'development') {
console.error('Token file exists');
}
const rawData = fs.readFileSync(TOKEN_STORE_PATH, 'utf-8');
if (process.env.NODE_ENV === 'development') {
console.error('Raw token data length:', rawData.length);
}
const encryptedData = JSON.parse(rawData);
if (process.env.NODE_ENV === 'development') {
console.error('Parsed encrypted data:', {
hasIv: !!encryptedData.iv,
encryptedDataLength: encryptedData.encryptedData?.length
});
}
const decrypted = decryptData(encryptedData);
if (process.env.NODE_ENV === 'development') {
console.error('Token data decrypted successfully');
}
return decrypted;
}
else {
if (process.env.NODE_ENV === 'development') {
console.error('Token file not found');
}
}
}
catch (error) {
console.error('Error loading token data:', error);
if (process.env.NODE_ENV === 'development') {
console.error('Current working directory:', process.cwd());
}
throw new McpError(ErrorCode.InternalError, 'Failed to load token data. The token file may be corrupted or encryption key may be invalid.');
}
return null;
}
function saveTokenData(data) {
try {
const encryptedData = encryptData(data);
fs.writeFileSync(TOKEN_STORE_PATH, JSON.stringify(encryptedData, null, 2));
tokenData = data;
}
catch (error) {
console.error('Error saving token data:', error);
throw new McpError(ErrorCode.InternalError, 'Failed to save token data. Please check if the encryption key is properly set.');
}
}
function generateAuthUrl() {
const { codeVerifier, codeChallenge } = generatePKCE();
const authUrl = new URL('https://www.dropbox.com/oauth2/authorize');
// During setup, use process.env directly
const isSetup = process.argv[1]?.endsWith('setup.js');
const clientId = isSetup ? process.env.DROPBOX_APP_KEY : validatedAppKey;
authUrl.searchParams.append('client_id', clientId);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('redirect_uri', 'http://localhost');
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('token_access_type', 'offline');
return {
url: authUrl.toString(),
codeVerifier
};
}
async function exchangeCodeForTokens(code, codeVerifier) {
try {
// During setup, use process.env directly
const isSetup = process.argv[1]?.endsWith('setup.js');
const clientId = isSetup ? process.env.DROPBOX_APP_KEY : validatedAppKey;
const clientSecret = isSetup ? process.env.DROPBOX_APP_SECRET : validatedAppSecret;
const params = new URLSearchParams({
code,
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
redirect_uri: 'http://localhost',
code_verifier: codeVerifier
});
const response = await axios.post('https://api.dropboxapi.com/oauth2/token', params.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const tokenData = {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
expiresAt: Date.now() + (response.data.expires_in * 1000),
scope: response.data.scope.split(' '),
codeVerifier // Store code verifier for token refresh
};
saveTokenData(tokenData);
return tokenData;
}
catch (error) {
if (axios.isAxiosError(error)) {
console.error('Error exchanging code for tokens:', error.response?.data);
throw new McpError(ErrorCode.InternalError, `Failed to exchange authorization code for tokens: ${error.response?.data?.error_description || error.message}`);
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
console.error('Error exchanging code for tokens:', errorMessage);
throw new McpError(ErrorCode.InternalError, `Failed to exchange authorization code for tokens: ${errorMessage}`);
}
}
async function refreshAccessToken() {
if (!tokenData?.refreshToken) {
throw new McpError(ErrorCode.InvalidRequest, 'No refresh token available. Please complete authentication first by visiting the authorization URL.');
}
// Check if we're within the retry cooldown period
const now = Date.now();
if (tokenData.lastRefreshAttempt &&
(now - tokenData.lastRefreshAttempt) < TOKEN_REFRESH_CONFIG.retryDelay) {
throw new TokenRefreshError('Too many refresh attempts. Please wait before trying again.', 'RATE_LIMIT', true);
}
// Update refresh attempt counters
tokenData.lastRefreshAttempt = now;
tokenData.refreshAttempts = (tokenData.refreshAttempts || 0) + 1;
if (tokenData.refreshAttempts > TOKEN_REFRESH_CONFIG.maxRetries) {
throw new TokenRefreshError(ERROR_MESSAGES.REFRESH_FAILED, 'MAX_RETRIES_EXCEEDED', false);
}
try {
const searchParams = new URLSearchParams({
refresh_token: tokenData.refreshToken,
grant_type: 'refresh_token',
client_id: validatedAppKey,
client_secret: validatedAppSecret
});
const response = await axios.post('https://api.dropboxapi.com/oauth2/token', searchParams.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
timeout: 10000 // 10 second timeout
});
// Reset refresh attempt counters on success
const newTokenData = {
...tokenData,
accessToken: response.data.access_token,
expiresAt: Date.now() + (response.data.expires_in * 1000),
refreshAttempts: 0,
lastRefreshAttempt: undefined
};
saveTokenData(newTokenData);
return newTokenData.accessToken;
}
catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error;
const statusCode = axiosError.response?.status ?? 0;
const errorData = axiosError.response?.data;
// Handle specific error cases
if (statusCode === 401 && errorData?.error === 'invalid_grant') {
throw new TokenRefreshError(ERROR_MESSAGES.INVALID_GRANT, 'INVALID_GRANT', false);
}
else if (statusCode === 429) {
throw new TokenRefreshError(ERROR_MESSAGES.RATE_LIMIT, 'RATE_LIMIT', true);
}
else if (statusCode >= 500) {
throw new TokenRefreshError(ERROR_MESSAGES.SERVER_ERROR, 'SERVER_ERROR', true);
}
else if (!axiosError.response) {
throw new TokenRefreshError(ERROR_MESSAGES.NETWORK_ERROR, 'NETWORK_ERROR', true);
}
}
// Generic error case
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
throw new TokenRefreshError(`Token refresh failed: ${errorMessage}`, 'UNKNOWN_ERROR', true);
}
}
async function getValidAccessToken() {
if (!tokenData) {
throw new McpError(ErrorCode.InvalidRequest, 'No token data available. Please complete authentication first by visiting the authorization URL.');
}
const refreshThreshold = TOKEN_REFRESH_CONFIG.thresholdMinutes * 60 * 1000;
// Check if token is expired or will expire soon
if (Date.now() >= (tokenData.expiresAt - refreshThreshold)) {
console.log(ERROR_MESSAGES.TOKEN_EXPIRED);
let retryCount = 0;
while (retryCount < TOKEN_REFRESH_CONFIG.maxRetries) {
try {
return await refreshAccessToken();
}
catch (error) {
if (error instanceof TokenRefreshError) {
if (!error.retryable) {
throw new McpError(ErrorCode.InvalidRequest, error.message);
}
retryCount++;
if (retryCount < TOKEN_REFRESH_CONFIG.maxRetries) {
await new Promise(resolve => setTimeout(resolve, TOKEN_REFRESH_CONFIG.retryDelay));
continue;
}
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
throw new McpError(ErrorCode.InvalidRequest, `Token refresh failed after ${retryCount} attempts: ${errorMessage}`);
}
}
throw new McpError(ErrorCode.InvalidRequest, ERROR_MESSAGES.REFRESH_FAILED);
}
return tokenData.accessToken;
}
export { generateAuthUrl, exchangeCodeForTokens, refreshAccessToken, getValidAccessToken, loadTokenData, saveTokenData };
//# sourceMappingURL=auth.js.map