@kaminaduck/scryfall-mcp-server
Version:
A Model Context Protocol (MCP) server that provides access to the Scryfall API for Magic: The Gathering card data
479 lines • 21.1 kB
JavaScript
/**
* Configuration module for Scryfall MCP server and standalone scripts.
*/
import { tmpdir, homedir, platform } from 'node:os';
import { join } from 'node:path';
import { mkdir, writeFile, unlink, access } from 'node:fs/promises';
import { existsSync, constants } from 'node:fs';
import { logger } from './logger.js';
/**
* Detect if running under Claude Desktop context.
* @returns True if Claude Desktop is detected
*/
function detectClaudeDesktopContext() {
// Check various indicators of Claude Desktop execution
return !!process.env['CLAUDE_DESKTOP'] ||
process.cwd().includes('AnthropicClaude') ||
process.env['npm_execpath']?.includes('AnthropicClaude') ||
process.env['npm_config_user_agent']?.includes('Claude') ||
// macOS-specific Claude Desktop indicators
process.cwd().includes('com.anthropic.claude') ||
process.env['HOME']?.includes('/Library/Application Support/Claude') ||
false;
}
/**
* Resolve Windows environment variables that may not be properly expanded by Claude Desktop.
* @param path Path that may contain unresolved environment variables
* @returns Path with environment variables resolved
*/
function resolveWindowsEnvironmentVariables(path) {
if (platform() !== 'win32') {
return path;
}
// Detect Claude Desktop context
const isClaudeDesktop = detectClaudeDesktopContext();
// Handle common unresolved Windows environment variables
let resolvedPath = path;
let hasUnresolvedVars = false;
// Check for literal ${APPDATA} and resolve it
if (resolvedPath.includes('${APPDATA}')) {
hasUnresolvedVars = true;
const appData = process.env['APPDATA'] || getWindowsAppDataPath();
resolvedPath = resolvedPath.replace(/\$\{APPDATA\}/g, appData);
logger.info(`[Config] Resolved ${path} to ${resolvedPath}`);
}
// Check for literal ${LOCALAPPDATA} and resolve it
if (resolvedPath.includes('${LOCALAPPDATA}')) {
hasUnresolvedVars = true;
const localAppData = process.env['LOCALAPPDATA'] ||
join(process.env['USERPROFILE'] || homedir(), 'AppData', 'Local');
resolvedPath = resolvedPath.replace(/\$\{LOCALAPPDATA\}/g, localAppData);
logger.info(`[Config] Resolved ${path} to ${resolvedPath}`);
}
// Check for literal ${USERPROFILE} and resolve it
if (resolvedPath.includes('${USERPROFILE}')) {
hasUnresolvedVars = true;
const userProfile = process.env['USERPROFILE'] || homedir();
resolvedPath = resolvedPath.replace(/\$\{USERPROFILE\}/g, userProfile);
logger.info(`[Config] Resolved ${path} to ${resolvedPath}`);
}
// Also handle %VAR% style Windows variables
const percentPattern = /%([^%]+)%/g;
if (percentPattern.test(resolvedPath)) {
hasUnresolvedVars = true;
resolvedPath = resolvedPath.replace(percentPattern, (match, varName) => {
const value = process.env[varName];
if (value) {
logger.info(`[Config] Resolved %${varName}% to ${value}`);
return value;
}
logger.warn(`[Config] Unable to resolve %${varName}% - environment variable not found`);
return match;
});
}
if (hasUnresolvedVars && isClaudeDesktop) {
logger.warn('[Config] Claude Desktop environment variable expansion issue detected');
logger.warn('[Config] Applied manual resolution for Windows paths');
}
return resolvedPath;
}
/**
* Get the Windows AppData path with fallback mechanisms.
* @returns Path to the Windows AppData directory
*/
function getWindowsAppDataPath() {
if (platform() !== 'win32') {
return homedir();
}
// Try to get APPDATA from environment (ensure it's not unresolved)
if (process.env['APPDATA'] && !process.env['APPDATA'].includes('${')) {
return process.env['APPDATA'];
}
// Try to construct from USERPROFILE (ensure it's not unresolved)
if (process.env['USERPROFILE'] && !process.env['USERPROFILE'].includes('${')) {
return join(process.env['USERPROFILE'], 'AppData', 'Roaming');
}
// Try to get from known Claude Desktop paths if environment variables are unresolved
const claudeDesktopPaths = [
join(homedir(), 'AppData', 'Roaming'),
'C:\\Users\\Default\\AppData\\Roaming',
join('C:\\Users', process.env['USERNAME'] || 'User', 'AppData', 'Roaming')
];
for (const fallbackPath of claudeDesktopPaths) {
if (existsSync(fallbackPath)) {
logger.info(`[Config] Using fallback AppData path: ${fallbackPath}`);
return fallbackPath;
}
}
// Last resort: construct from homedir
return join(homedir(), 'AppData', 'Roaming');
}
/**
* Get the macOS Application Support path with fallback mechanisms.
* @returns Path to the macOS Application Support directory
*/
function getMacOSApplicationSupportPath() {
if (platform() !== 'darwin') {
return homedir();
}
// Standard macOS application support paths
const appSupportPaths = [
join(homedir(), 'Library', 'Application Support'),
join('/Library', 'Application Support'),
join(homedir(), '.local', 'share') // XDG fallback
];
// Check if we can access the standard paths
for (const appPath of appSupportPaths) {
try {
if (existsSync(appPath)) {
// Try to check if we can write to this directory
const testPath = join(appPath, '.scryfall_test');
try {
// Don't actually create the test file, just check parent directory
if (existsSync(appPath)) {
return appPath;
}
}
catch (e) {
logger.warn(`[Config] Cannot use ${appPath}: ${e}`);
}
}
}
catch (error) {
logger.warn(`[Config] Error checking ${appPath}: ${error}`);
}
}
// Fallback to home directory
logger.warn('[Config] Using home directory as fallback for macOS Application Support');
return homedir();
}
/**
* Detect if running in MCP mode.
* @returns True if running in MCP mode, false otherwise
*/
export function isMcpMode() {
// Check for MCP-specific environment variables or execution context
return process.env['MCP_SERVER_NAME'] !== undefined ||
process.env['MCP_ENABLE_FILE_DOWNLOADS'] !== undefined;
}
/**
* Validate that a directory exists and is writable, throwing descriptive errors if not.
* @param directory Path to validate
* @throws Error with descriptive message if directory is invalid
*/
export async function validateStorageDirectory(directory) {
try {
// Check if path exists
await access(directory, constants.F_OK);
// Check if it's writable
await access(directory, constants.W_OK);
// Try to write a test file to ensure write permissions work
const testFile = join(directory, '.scryfall_write_test');
try {
await writeFile(testFile, 'test');
await unlink(testFile);
}
catch (writeError) {
// More detailed error for write failures
if (platform() === 'darwin' && writeError.code === 'EACCES') {
throw new Error(`Directory ${directory} exists but is not writable due to macOS permissions.\n` +
`This may be due to:\n` +
`1. System Integrity Protection (SIP) restrictions\n` +
`2. App Sandbox restrictions in Claude Desktop\n` +
`3. File system permissions\n` +
`Try setting SCRYFALL_DATA_DIR to a user-writable location like:\n` +
` export SCRYFALL_DATA_DIR="$HOME/Documents/scryfall_mcp"\n` +
`Or use: export SCRYFALL_DATA_DIR="/tmp/scryfall_mcp"`);
}
throw new Error(`Directory ${directory} exists but is not writable: ${writeError.message || writeError}`);
}
}
catch (error) {
if (error.code === 'ENOENT') {
const isClaudeDesktop = detectClaudeDesktopContext();
if (isClaudeDesktop && platform() === 'win32' && directory.includes('${')) {
throw new Error(`Storage directory path contains unresolved variables: ${directory}\n` +
`This is a known Claude Desktop issue on Windows. Please either:\n` +
`1. Use global installation: npm install -g @kaminaduck/scryfall-mcp-server\n` +
`2. Set SCRYFALL_DATA_DIR to a full path without variables\n` +
`3. Use the Windows wrapper script\n` +
`See README for detailed solutions.`);
}
else if (isClaudeDesktop && platform() === 'darwin') {
throw new Error(`Storage directory does not exist: ${directory}\n` +
`Claude Desktop on macOS may have restricted file access. Try:\n` +
`1. Set SCRYFALL_DATA_DIR to an accessible location:\n` +
` export SCRYFALL_DATA_DIR="$HOME/Documents/scryfall_mcp"\n` +
`2. Or use the temp directory:\n` +
` export SCRYFALL_DATA_DIR="/tmp/scryfall_mcp"\n` +
`3. Ensure Claude Desktop has file system permissions in System Settings`);
}
throw new Error(`Storage directory does not exist: ${directory}`);
}
else if (error.code === 'EACCES') {
if (platform() === 'darwin') {
throw new Error(`Permission denied for directory: ${directory}\n` +
`On macOS, this typically happens due to:\n` +
`1. System Integrity Protection (SIP) - cannot write to system directories\n` +
`2. App Sandbox restrictions - Claude Desktop may have limited file access\n` +
`3. Standard UNIX permissions - directory owned by another user\n` +
`Solutions:\n` +
`- Set SCRYFALL_DATA_DIR to a writable location\n` +
`- Grant Claude Desktop file access in System Settings > Privacy & Security`);
}
throw new Error(`Permission denied for directory: ${directory}`);
}
else if (error.code === 'EROFS') {
throw new Error(`Directory ${directory} is on a read-only file system`);
}
else if (error.code === 'ENOTDIR') {
throw new Error(`Path exists but is not a directory: ${directory}`);
}
else if (error.message?.includes('not writable')) {
throw error;
}
else {
throw new Error(`Invalid storage directory ${directory}: ${error.message || error}`);
}
}
}
/**
* Get the appropriate storage directory based on execution mode.
* @param subdirectory Optional subdirectory name within the storage directory
* @returns Path string for the storage directory
*/
export async function getStorageDirectory(subdirectory) {
let baseDir;
try {
// Check for environment variable override with Windows variable resolution
if (process.env['SCRYFALL_DATA_DIR']) {
baseDir = resolveWindowsEnvironmentVariables(process.env['SCRYFALL_DATA_DIR']);
logger.info(`Using environment variable SCRYFALL_DATA_DIR: ${baseDir}`);
}
else if (isMcpMode()) {
// In MCP mode, use temp directory or XDG cache with Windows-specific handling
const xdgCache = process.env['XDG_CACHE_HOME'];
if (xdgCache && platform() !== 'win32') {
baseDir = join(xdgCache, 'scryfall_mcp');
logger.info(`Using XDG cache directory: ${baseDir}`);
}
else if (platform() === 'win32') {
// Windows-specific path handling for MCP mode
try {
const appData = getWindowsAppDataPath();
baseDir = join(appData, 'scryfall_mcp');
logger.info(`Using Windows AppData directory for MCP: ${baseDir}`);
// Double-check for Claude Desktop issues
if (baseDir.includes('${')) {
logger.error('[Config] Unresolved variables detected in AppData path!');
logger.error('[Config] Falling back to alternative directory...');
baseDir = join(tmpdir(), 'scryfall_downloads');
}
}
catch (windowsError) {
logger.warn(`Windows AppData resolution failed, using temp: ${windowsError}`);
baseDir = join(tmpdir(), 'scryfall_downloads');
logger.info(`Using temporary directory fallback: ${baseDir}`);
}
}
else if (platform() === 'darwin') {
// macOS-specific path handling for MCP mode
try {
const appSupport = getMacOSApplicationSupportPath();
baseDir = join(appSupport, 'scryfall_mcp');
logger.info(`Using macOS Application Support directory for MCP: ${baseDir}`);
}
catch (macError) {
logger.warn(`macOS Application Support resolution failed: ${macError}`);
// Try XDG_CACHE_HOME as fallback
if (process.env['XDG_CACHE_HOME']) {
baseDir = join(process.env['XDG_CACHE_HOME'], 'scryfall_mcp');
logger.info(`Using XDG cache directory: ${baseDir}`);
}
else {
baseDir = join(tmpdir(), 'scryfall_downloads');
logger.info(`Using temporary directory fallback: ${baseDir}`);
}
}
}
else {
baseDir = join(tmpdir(), 'scryfall_downloads');
logger.info(`Using temporary directory: ${baseDir}`);
}
}
else {
// Standalone mode - use traditional .local directory with platform-specific handling
if (platform() === 'win32') {
const appData = getWindowsAppDataPath();
baseDir = join(appData, 'scryfall_mcp');
logger.info(`Using Windows AppData for standalone mode: ${baseDir}`);
}
else if (platform() === 'darwin') {
const appSupport = getMacOSApplicationSupportPath();
baseDir = join(appSupport, 'scryfall_mcp');
logger.info(`Using macOS Application Support for standalone mode: ${baseDir}`);
}
else {
baseDir = join(homedir(), '.local');
logger.info(`Using home directory storage: ${baseDir}`);
}
}
// Resolve any remaining Windows environment variables in the final path
baseDir = resolveWindowsEnvironmentVariables(baseDir);
// Add subdirectory if specified
const storageDir = subdirectory ? join(baseDir, subdirectory) : baseDir;
// Ensure directory exists with better error handling
try {
await mkdir(storageDir, { recursive: true });
logger.info(`Created/verified storage directory: ${storageDir}`);
}
catch (mkdirError) {
throw new Error(`Failed to create storage directory ${storageDir}: ${mkdirError.message || mkdirError}`);
}
// Validate the directory is accessible and writable
await validateStorageDirectory(storageDir);
return storageDir;
}
catch (error) {
// Fallback mechanism - try alternative directories with platform-specific paths
logger.error(`Error with primary storage directory: ${error.message}`);
let fallbackDirs = [];
if (platform() === 'win32') {
fallbackDirs = [
join(tmpdir(), 'scryfall_fallback'),
join(process.env['USERPROFILE'] || homedir(), 'Documents', 'scryfall_fallback'),
join(process.env['LOCALAPPDATA'] || join(homedir(), 'AppData', 'Local'), 'scryfall_mcp'),
join('C:\\ProgramData', 'scryfall_mcp'),
join(process.cwd(), 'temp_scryfall')
];
}
else if (platform() === 'darwin') {
fallbackDirs = [
join(tmpdir(), 'scryfall_fallback'),
join(homedir(), 'Documents', 'scryfall_mcp'),
join(homedir(), '.scryfall_mcp'),
join(homedir(), 'Library', 'Caches', 'scryfall_mcp'),
join('/tmp', 'scryfall_mcp'),
join(process.cwd(), 'temp_scryfall')
];
}
else {
fallbackDirs = [
join(tmpdir(), 'scryfall_fallback'),
join(homedir(), '.cache', 'scryfall_mcp'),
join(homedir(), '.local', 'share', 'scryfall_mcp'),
join(process.cwd(), 'temp_scryfall')
];
}
for (const fallback of fallbackDirs) {
try {
const fallbackPath = subdirectory ? join(fallback, subdirectory) : fallback;
await mkdir(fallbackPath, { recursive: true });
await validateStorageDirectory(fallbackPath);
logger.info(`Using fallback directory: ${fallbackPath}`);
return fallbackPath;
}
catch (fallbackError) {
logger.error(`Fallback ${fallback} also failed: ${fallbackError}`);
continue;
}
}
throw new Error(`Unable to create or access any storage directory. Last error: ${error.message}`);
}
}
/**
* Get the directory for card images.
* @returns Path string for the card images directory
*/
export async function getCardImagesDirectory() {
if (isMcpMode()) {
return await getStorageDirectory('scryfall_card_images');
}
else {
return await getStorageDirectory('scryfall_card_images');
}
}
/**
* Get the directory for art crops.
* @returns Path string for the art crops directory
*/
export async function getArtCropsDirectory() {
if (isMcpMode()) {
return await getStorageDirectory('scryfall_images');
}
else {
return await getStorageDirectory('scryfall_images');
}
}
/**
* Get the path for the database file.
* @returns Path string for the database file
*/
export async function getDatabasePath() {
const storageDir = await getStorageDirectory();
return join(storageDir, 'scryfall_database.db');
}
/**
* Check if we have write permissions to a directory.
* @param directory Path to check
* @returns True if we can write to the directory, false otherwise
*/
export async function ensureDirectoryPermissions(directory) {
try {
// First check if directory exists
await access(directory, constants.F_OK);
// Check read and write permissions
await access(directory, constants.R_OK | constants.W_OK);
// Try to create a test file with more robust error handling
const testFile = join(directory, `.permission_test_${Date.now()}`);
try {
await writeFile(testFile, 'test');
await unlink(testFile);
return true;
}
catch (writeError) {
// Log specific error for debugging
if (platform() === 'darwin') {
logger.warn(`[Config] macOS write test failed in ${directory}: ${writeError.code} - ${writeError.message}`);
if (writeError.code === 'EACCES') {
logger.warn('[Config] This may be due to App Sandbox or SIP restrictions');
}
}
else {
logger.warn(`[Config] Write test failed in ${directory}: ${writeError.message}`);
}
return false;
}
}
catch (error) {
// Log why permission check failed
if (error.code === 'ENOENT') {
logger.warn(`[Config] Directory does not exist: ${directory}`);
}
else if (error.code === 'EACCES') {
logger.warn(`[Config] No access permissions for directory: ${directory}`);
}
else {
logger.warn(`[Config] Permission check failed: ${error.message}`);
}
return false;
}
}
/**
* Generate a file path within the base directory.
* @param baseDir Base directory path
* @param filename Name of the file
* @returns Full path to the file
*/
export function getFilePath(baseDir, filename) {
return join(baseDir, filename);
}
/**
* Check if a directory exists.
* @param path Path to check
* @returns True if the directory exists, false otherwise
*/
export function directoryExists(path) {
return existsSync(path);
}
//# sourceMappingURL=config.js.map