diff --git a/.env.example b/.env.example index 59120a98..c2c1f484 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,12 @@ SESSION_SECRET=dev-session-secret-change-in-production # Local: redis://localhost:6379 # REDIS_URL=redis://localhost:6379 +# Redis key prefix for multi-app isolation (default: no prefix) +# Set this to run multiple MCP apps on the same Redis instance without key conflicts +# Example: 'mcp-main' creates keys like 'mcp-main:oauth:client:abc123' +# Note: Colon separator is added automatically if not present +# REDIS_KEY_PREFIX=mcp-persistence + # ===== LLM Provider Configuration ===== # At least one provider is required for MCP tool functionality # ANTHROPIC_API_KEY=your_claude_api_key diff --git a/CHANGELOG.md b/CHANGELOG.md index 413e641a..e1f4a7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.9.1-rc.2] - 2025-11-28 + +### Added + +- **Redis key prefix support for multi-tenant deployments** (addresses Vercel canary deployment needs) + - **Problem**: Multiple MCP apps sharing the same Redis instance had key conflicts (e.g., both writing to `oauth:client:abc123`) + - **Solution**: Added `REDIS_KEY_PREFIX` environment variable with automatic colon separator normalization + - **Impact**: Can now run multiple MCP apps on same Redis: `REDIS_KEY_PREFIX=mcp-main` creates `mcp-main:oauth:client:`, `mcp-canary` creates `mcp-canary:oauth:client:` + - Backward compatible: Empty prefix by default (existing deployments unaffected) + - Documented in all .env templates with default value `mcp-persistence` + - DRY implementation: Single `getRedisKeyPrefix()` utility used across all 6 factories + +### Fixed + +- **Fixed system tests failing after scaffolding** (Issue discovered in canary project testing) + - **Problem**: Scaffolded projects had system tests failing with CORS errors and module resolution issues + - **Solution**: Removed ALLOWED_ORIGINS from test environment (allows all origins for local testing) and fixed NODE_ENV to use 'test' mode + - **Impact**: Scaffolded projects now have passing system tests out-of-the-box (16/16 tests pass) + +- **Fixed module resolution in scaffolded projects** (CRITICAL) + - **Problem**: Vitest configuration included workspace-style path aliases (`@mcp-typescript-simple/tools: '../tools/src'`) that only work in monorepo environments + - **Solution**: Removed `resolve.alias` configuration from vitest.config.ts template - packages now resolve from node_modules + - **Impact**: Tests and builds work correctly in standalone scaffolded projects + +- **Added current directory scaffolding support** + - **Problem**: Cannot scaffold into existing directory - common workflow blocked (clone GitHub repo → scaffold into it) + - **Solution**: Accept "." as special case to scaffold into current directory, using directory name as project name + - **Impact**: Developers can now scaffold directly into cloned repositories: `npx create-mcp-typescript-simple@next . --yes` + +- **Added port configuration documentation** + - **Problem**: Port conflicts can cause test failures, but users don't know how to change ports + - **Solution**: Added helpful comments in vitest.system.config.ts explaining port configuration and alternatives + - **Impact**: Users understand how to resolve port conflicts when they occur + +### Changed + +- System test environment now uses `NODE_ENV=test` instead of `NODE_ENV=development` for consistency with framework +- CORS policy in test environment now allows all origins (no ALLOWED_ORIGINS restriction) for local testing +- Added clarifying comments about production CORS configuration in test setup files + +--- + ## [0.9.0] - 2025-11-18 ### First Public Release diff --git a/packages/config/src/storage-config.ts b/packages/config/src/storage-config.ts index d82c4c15..3e5795cd 100644 --- a/packages/config/src/storage-config.ts +++ b/packages/config/src/storage-config.ts @@ -12,6 +12,10 @@ export const StorageConfigSchema = z.object({ // Redis connection REDIS_URL: z.string().url().optional(), + // Redis key prefix for multi-app isolation (default: '' for backward compatibility) + // Example: 'mcp-main:' or 'mcp-canary:' to run multiple apps on same Redis instance + REDIS_KEY_PREFIX: z.string().optional().default(''), + // Explicit storage type selection (optional - auto-detect if not set) STORAGE_TYPE: z.enum(['memory', 'file', 'redis']).optional(), diff --git a/packages/create-mcp-typescript-simple/src/generator.ts b/packages/create-mcp-typescript-simple/src/generator.ts index 4d4f92b7..5568ed8e 100644 --- a/packages/create-mcp-typescript-simple/src/generator.ts +++ b/packages/create-mcp-typescript-simple/src/generator.ts @@ -139,15 +139,17 @@ function generatePackageJson(config: ProjectConfig): object { */ export async function generateProject(config: ProjectConfig, targetDir: string): Promise { const projectPath = path.resolve(process.cwd(), targetDir); + const isCurrentDir = targetDir === '.'; console.log(chalk.cyan('\n📦 Generating project structure...\n')); // Check if directory exists and is not empty - if (await isDirNonEmpty(projectPath)) { + // Allow scaffolding into current directory (targetDir === ".") + if (!isCurrentDir && await isDirNonEmpty(projectPath)) { throw new Error(`Directory ${projectPath} already exists and is not empty`); } - // Ensure project directory exists + // Ensure project directory exists (no-op if current directory) await ensureDir(projectPath); // Generate template data diff --git a/packages/create-mcp-typescript-simple/src/index.ts b/packages/create-mcp-typescript-simple/src/index.ts index 41828cfc..8558e0bf 100644 --- a/packages/create-mcp-typescript-simple/src/index.ts +++ b/packages/create-mcp-typescript-simple/src/index.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import path, { dirname, join } from 'node:path'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import fs from 'fs-extra'; import { promptForConfig, getDefaultConfig } from './prompts.js'; import { generateProject } from './generator.js'; import type { CliOptions, ProjectConfig } from './types.js'; @@ -53,6 +54,61 @@ async function installDependencies(projectPath: string): Promise { } } +/** + * Validate and normalize project name + */ +function validateAndNormalizeProjectName(projectName: string | undefined): { targetDir: string; effectiveProjectName: string } { + // Handle special case: "." means current directory + if (projectName === '.') { + const effectiveProjectName = path.basename(process.cwd()); + + // Validate the derived name + if (!/^[a-z0-9-_]+$/.test(effectiveProjectName)) { + console.error(chalk.red('\n❌ Invalid directory name for project')); + console.error(chalk.yellow(` Current directory name "${effectiveProjectName}" must be lowercase with only letters, numbers, dashes, and underscores`)); + console.error(chalk.yellow(' Please rename the directory or create a new project with a valid name\n')); + process.exit(1); + } + + return { targetDir: '.', effectiveProjectName }; + } + + // Validate project name if provided + if (projectName && !/^[a-z0-9-_]+$/.test(projectName)) { + console.error(chalk.red('\n❌ Invalid project name')); + console.error(chalk.yellow(' Project name must be lowercase with only letters, numbers, dashes, and underscores\n')); + process.exit(1); + } + + return { + targetDir: projectName ?? '', + effectiveProjectName: projectName ?? '' + }; +} + +/** + * Get project configuration from user or defaults + */ +async function getProjectConfig(effectiveProjectName: string, projectName: string | undefined, options: CliOptions): Promise { + if (options.yes) { + if (!effectiveProjectName) { + console.error(chalk.red('\n❌ Project name is required with --yes flag\n')); + process.exit(1); + } + + const config = await getDefaultConfig(effectiveProjectName); + if (projectName === '.') { + console.log(chalk.cyan(`\n✨ Scaffolding into current directory with project name "${effectiveProjectName}"\n`)); + } else { + console.log(chalk.cyan(`\n✨ Creating ${effectiveProjectName} with default configuration\n`)); + } + return config; + } + + // Interactive prompts + return await promptForConfig(effectiveProjectName); +} + /** * Display next steps for the user */ @@ -98,40 +154,31 @@ program .option('-y, --yes', 'Skip prompts and use defaults') .action(async (projectName: string | undefined, options: CliOptions) => { try { - // Validate project name if provided - if (projectName && !/^[a-z0-9-_]+$/.test(projectName)) { - console.error(chalk.red('\n❌ Invalid project name')); - console.error(chalk.yellow(' Project name must be lowercase with only letters, numbers, dashes, and underscores\n')); - process.exit(1); - } + // Validate and normalize project name + const { targetDir, effectiveProjectName } = validateAndNormalizeProjectName(projectName); - let config: ProjectConfig; + // Get project configuration (interactive or defaults) + const config = await getProjectConfig(effectiveProjectName, projectName, options); - // Get configuration - if (options.yes) { - if (!projectName) { - console.error(chalk.red('\n❌ Project name is required with --yes flag\n')); - process.exit(1); - } + // Generate project (pass targetDir which may be ".") + const projectTargetDir = targetDir || config.name; + await generateProject(config, projectTargetDir); - config = await getDefaultConfig(projectName); - console.log(chalk.cyan(`\n✨ Creating ${projectName} with default configuration\n`)); + const projectPath = path.resolve(process.cwd(), projectTargetDir); + + // Initialize git only if not already a git repository + const isGitRepo = await fs.pathExists(path.join(projectPath, '.git')); + if (!isGitRepo) { + await initGit(projectPath); } else { - // Interactive prompts - config = await promptForConfig(projectName); + console.log(chalk.cyan('ℹ️ Git repository already exists, skipping initialization\n')); } - // Generate project - await generateProject(config, config.name); - - // Always initialize git - await initGit(path.resolve(process.cwd(), config.name)); - // Always install dependencies - await installDependencies(path.resolve(process.cwd(), config.name)); + await installDependencies(projectPath); // Display next steps - displayNextSteps(config, config.name); + displayNextSteps(config, projectTargetDir); } catch (error) { console.error(chalk.red('\n❌ Error creating project:')); console.error(chalk.gray(` ${error instanceof Error ? error.message : String(error)}\n`)); diff --git a/packages/create-mcp-typescript-simple/templates/env.example.hbs b/packages/create-mcp-typescript-simple/templates/env.example.hbs index 5b3bc211..33b095ad 100644 --- a/packages/create-mcp-typescript-simple/templates/env.example.hbs +++ b/packages/create-mcp-typescript-simple/templates/env.example.hbs @@ -103,6 +103,12 @@ SESSION_SECRET=dev-session-secret-change-in-production # Local: redis://localhost:6379 # REDIS_URL=redis://localhost:6379 +# Redis key prefix for multi-app isolation (default: no prefix) +# Set this to run multiple MCP apps on the same Redis instance without key conflicts +# Example: 'mcp-main' creates keys like 'mcp-main:oauth:client:abc123' +# Note: Colon separator is added automatically if not present +REDIS_KEY_PREFIX=mcp-persistence + # ===== LLM Provider Configuration ===== # At least one provider is required for MCP tool functionality # ANTHROPIC_API_KEY=your_claude_api_key diff --git a/packages/create-mcp-typescript-simple/templates/env.local.hbs b/packages/create-mcp-typescript-simple/templates/env.local.hbs index 3a79f0a9..f70fecdc 100644 --- a/packages/create-mcp-typescript-simple/templates/env.local.hbs +++ b/packages/create-mcp-typescript-simple/templates/env.local.hbs @@ -18,3 +18,9 @@ TOKEN_ENCRYPTION_KEY={{{tokenEncryptionKey}}} # Override Redis URL (optional) # REDIS_URL=redis://localhost:6379 + +# Redis key prefix for multi-app isolation (default: no prefix) +# Set this to run multiple MCP apps on the same Redis instance without key conflicts +# Example: 'mcp-main' creates keys like 'mcp-main:oauth:client:abc123' +# Note: Colon separator is added automatically if not present +# REDIS_KEY_PREFIX=mcp-persistence diff --git a/packages/create-mcp-typescript-simple/templates/env.oauth.docker.hbs b/packages/create-mcp-typescript-simple/templates/env.oauth.docker.hbs index cfc44d89..ab5c45fa 100644 --- a/packages/create-mcp-typescript-simple/templates/env.oauth.docker.hbs +++ b/packages/create-mcp-typescript-simple/templates/env.oauth.docker.hbs @@ -55,5 +55,15 @@ TOKEN_ENCRYPTION_KEY={{{tokenEncryptionKey}}} # OAUTH_PROVIDER_NAME=Your Custom Provider # OAUTH_SCOPES=openid,profile,email +# ===== Redis Configuration ===== +# Redis URL is auto-configured by docker-compose.yml (redis://redis:6379) +# For non-Docker deployments, set manually: redis://localhost:6379 + +# Redis key prefix for multi-app isolation (default: no prefix) +# Set this to run multiple MCP apps on the same Redis instance without key conflicts +# Example: 'mcp-main' creates keys like 'mcp-main:oauth:client:abc123' +# Note: Colon separator is added automatically if not present +# REDIS_KEY_PREFIX=mcp-persistence + # ===== Environment ===== NODE_ENV=development diff --git a/packages/create-mcp-typescript-simple/templates/env.oauth.example.hbs b/packages/create-mcp-typescript-simple/templates/env.oauth.example.hbs index c5092fb3..9a343053 100644 --- a/packages/create-mcp-typescript-simple/templates/env.oauth.example.hbs +++ b/packages/create-mcp-typescript-simple/templates/env.oauth.example.hbs @@ -46,3 +46,9 @@ NODE_ENV=development # === Redis (for session persistence and horizontal scaling) === REDIS_URL=redis://localhost:6379 + +# Redis key prefix for multi-app isolation (default: no prefix) +# Set this to run multiple MCP apps on the same Redis instance without key conflicts +# Example: 'mcp-main' creates keys like 'mcp-main:oauth:client:abc123' +# Note: Colon separator is added automatically if not present +REDIS_KEY_PREFIX=mcp-persistence diff --git a/packages/create-mcp-typescript-simple/templates/env.oauth.hbs b/packages/create-mcp-typescript-simple/templates/env.oauth.hbs index de775c54..62621c4c 100644 --- a/packages/create-mcp-typescript-simple/templates/env.oauth.hbs +++ b/packages/create-mcp-typescript-simple/templates/env.oauth.hbs @@ -46,3 +46,9 @@ NODE_ENV=development # === Redis (for session persistence and horizontal scaling) === REDIS_URL=redis://localhost:6379 + +# Redis key prefix for multi-app isolation (default: no prefix) +# Set this to run multiple MCP apps on the same Redis instance without key conflicts +# Example: 'mcp-main' creates keys like 'mcp-main:oauth:client:abc123' +# Note: Colon separator is added automatically if not present +REDIS_KEY_PREFIX=mcp-persistence diff --git a/packages/create-mcp-typescript-simple/templates/test/system/vitest-global-setup.ts.hbs b/packages/create-mcp-typescript-simple/templates/test/system/vitest-global-setup.ts.hbs index 90ae2f23..8663e0bf 100644 --- a/packages/create-mcp-typescript-simple/templates/test/system/vitest-global-setup.ts.hbs +++ b/packages/create-mcp-typescript-simple/templates/test/system/vitest-global-setup.ts.hbs @@ -160,12 +160,13 @@ export default async function globalSetup(): Promise { globalHttpServer = spawn('npx', ['tsx', 'src/index.ts'], { env: { ...process.env, - NODE_ENV: 'development', // Use development to show error details in test logs + NODE_ENV: 'test', // Use test environment to enable InMemoryTestTokenStore (no encryption needed) MCP_MODE: 'streamable_http', HTTP_PORT: httpPort, MCP_DEV_SKIP_AUTH: 'true', TOKEN_ENCRYPTION_KEY: 'Wp3suOcV+clee' + 'wUEOGUkE7JNgsnzwmiBMNqF7q9sQSI=', // Test key (split to avoid false positive secret detection) - ALLOWED_ORIGINS: `http://localhost:{{basePort}},http://localhost:{{add basePort 1}}`, // Allow CORS from test client ports + // Note: No ALLOWED_ORIGINS set - allows all origins for local testing + // Production deployments should set ALLOWED_ORIGINS explicitly in .env }, stdio: ['ignore', 'pipe', 'pipe'], detached: false, diff --git a/packages/create-mcp-typescript-simple/templates/vitest.config.ts b/packages/create-mcp-typescript-simple/templates/vitest.config.ts index 2a922ef3..efff8e60 100644 --- a/packages/create-mcp-typescript-simple/templates/vitest.config.ts +++ b/packages/create-mcp-typescript-simple/templates/vitest.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from 'vitest/config'; -import { resolve } from 'node:path'; export default defineConfig({ test: { @@ -18,16 +17,7 @@ export default defineConfig({ ], }, }, - resolve: { - alias: { - '@mcp-typescript-simple/tools': resolve(__dirname, '../tools/src'), - '@mcp-typescript-simple/tools-llm': resolve(__dirname, '../tools-llm/src'), - '@mcp-typescript-simple/example-tools-basic': resolve(__dirname, '../example-tools-basic/src'), - '@mcp-typescript-simple/example-tools-llm': resolve(__dirname, '../example-tools-llm/src'), - '@mcp-typescript-simple/server': resolve(__dirname, '../server/src'), - '@mcp-typescript-simple/http-server': resolve(__dirname, '../http-server/src'), - '@mcp-typescript-simple/config': resolve(__dirname, '../config/src'), - '@mcp-typescript-simple/observability': resolve(__dirname, '../observability/src'), - }, - }, + // Note: No path aliases needed - packages resolve from node_modules + // Workspace-style aliases like '@mcp-typescript-simple/tools': '../tools/src' + // only work in monorepo setups and break standalone project installations }); diff --git a/packages/create-mcp-typescript-simple/templates/vitest.system.config.ts b/packages/create-mcp-typescript-simple/templates/vitest.system.config.ts index 93c66255..64ceeb5b 100644 --- a/packages/create-mcp-typescript-simple/templates/vitest.system.config.ts +++ b/packages/create-mcp-typescript-simple/templates/vitest.system.config.ts @@ -10,6 +10,15 @@ export default defineConfig({ 'test/system/**/*.test.ts' ], + // Port configuration note: + // System tests use BASE_PORT defined in test/system/utils.ts + // Default is 3000 (configured during project scaffolding) + // If port 3000 conflicts with other services, you can: + // 1. Edit BASE_PORT in test/system/utils.ts + // 2. Use environment variable: HTTP_TEST_PORT=3010 npm run test:system + // The framework includes self-healing port management that automatically + // cleans up leaked processes from previous test runs. + // Exclude patterns exclude: [ '**/node_modules/**', diff --git a/packages/persistence/src/factories/client-store-factory.ts b/packages/persistence/src/factories/client-store-factory.ts index efc34c13..1e7312db 100644 --- a/packages/persistence/src/factories/client-store-factory.ts +++ b/packages/persistence/src/factories/client-store-factory.ts @@ -18,6 +18,7 @@ import { FileClientStore } from '../stores/file/file-client-store.js'; import { RedisClientStore } from '../stores/redis/redis-client-store.js'; import { logger } from '../logger.js'; import { getDataPath } from '../utils/data-paths.js'; +import { getRedisKeyPrefix } from '../stores/redis/redis-utils.js'; export interface ClientStoreFactoryOptions { /** Explicit store type (overrides auto-detection) */ @@ -130,10 +131,12 @@ export class ClientStoreFactory { throw new Error('Redis URL not configured. Set REDIS_URL environment variable.'); } + const keyPrefix = getRedisKeyPrefix(); + return new RedisClientStore(undefined, { defaultSecretExpirySeconds: options.defaultSecretExpirySeconds, maxClients: options.maxClients, - }); + }, keyPrefix); } /** diff --git a/packages/persistence/src/factories/mcp-metadata-store-factory.ts b/packages/persistence/src/factories/mcp-metadata-store-factory.ts index 0d775975..88c057b8 100644 --- a/packages/persistence/src/factories/mcp-metadata-store-factory.ts +++ b/packages/persistence/src/factories/mcp-metadata-store-factory.ts @@ -17,6 +17,7 @@ import { TokenEncryptionService } from '../encryption/token-encryption-service.j import { getSecretsProvider } from '@mcp-typescript-simple/config/secrets'; import { logger } from '../logger.js'; import { getDataPath } from '../utils/data-paths.js'; +import { getRedisKeyPrefix } from '../stores/redis/redis-utils.js'; export type MCPMetadataStoreType = 'memory' | 'file' | 'caching' | 'redis' | 'auto'; @@ -186,7 +187,9 @@ export class MCPMetadataStoreFactory { const encryptionService = new TokenEncryptionService({ encryptionKey }); - return new RedisMCPMetadataStore(redisUrl, encryptionService); + const keyPrefix = getRedisKeyPrefix(); + + return new RedisMCPMetadataStore(redisUrl, encryptionService, keyPrefix); } /** diff --git a/packages/persistence/src/factories/oauth-token-store-factory.ts b/packages/persistence/src/factories/oauth-token-store-factory.ts index 72b5d11f..76f884bd 100644 --- a/packages/persistence/src/factories/oauth-token-store-factory.ts +++ b/packages/persistence/src/factories/oauth-token-store-factory.ts @@ -19,6 +19,7 @@ import { RedisOAuthTokenStore } from '../stores/redis/redis-oauth-token-store.js import { TokenEncryptionService } from '../encryption/token-encryption-service.js'; import { getSecretsProvider } from '@mcp-typescript-simple/config/secrets'; import { logger } from '../logger.js'; +import { getRedisKeyPrefix } from '../stores/redis/redis-utils.js'; export type OAuthTokenStoreType = 'memory' | 'file' | 'redis' | 'auto'; @@ -156,7 +157,9 @@ export class OAuthTokenStoreFactory { // Create encryption service with loaded key const encryptionService = new TokenEncryptionService({ encryptionKey }); - return new RedisOAuthTokenStore(process.env.REDIS_URL, encryptionService); + const keyPrefix = getRedisKeyPrefix(); + + return new RedisOAuthTokenStore(process.env.REDIS_URL, encryptionService, keyPrefix); } /** diff --git a/packages/persistence/src/factories/pkce-store-factory.ts b/packages/persistence/src/factories/pkce-store-factory.ts index 542f713c..5bc3b474 100644 --- a/packages/persistence/src/factories/pkce-store-factory.ts +++ b/packages/persistence/src/factories/pkce-store-factory.ts @@ -13,6 +13,7 @@ import { PKCEStore } from '../interfaces/pkce-store.js'; import { MemoryPKCEStore } from '../stores/memory/memory-pkce-store.js'; import { RedisPKCEStore } from '../stores/redis/redis-pkce-store.js'; import { logger } from '../logger.js'; +import { getRedisKeyPrefix } from '../stores/redis/redis-utils.js'; export type PKCEStoreType = 'memory' | 'redis' | 'auto'; @@ -106,7 +107,9 @@ export class PKCEStoreFactory { ); } - return new RedisPKCEStore(); + const keyPrefix = getRedisKeyPrefix(); + + return new RedisPKCEStore(process.env.REDIS_URL, keyPrefix); } } diff --git a/packages/persistence/src/factories/session-store-factory.ts b/packages/persistence/src/factories/session-store-factory.ts index 678c3798..d52358de 100644 --- a/packages/persistence/src/factories/session-store-factory.ts +++ b/packages/persistence/src/factories/session-store-factory.ts @@ -13,6 +13,7 @@ import { OAuthSessionStore } from '../interfaces/session-store.js'; import { MemorySessionStore } from '../stores/memory/memory-session-store.js'; import { RedisSessionStore } from '../stores/redis/redis-session-store.js'; import { logger } from '../logger.js'; +import { getRedisKeyPrefix } from '../stores/redis/redis-utils.js'; export type SessionStoreType = 'memory' | 'redis' | 'auto'; @@ -84,7 +85,9 @@ export class SessionStoreFactory { ); } - return new RedisSessionStore(); + const keyPrefix = getRedisKeyPrefix(); + + return new RedisSessionStore(process.env.REDIS_URL, keyPrefix); } /** diff --git a/packages/persistence/src/factories/token-store-factory.ts b/packages/persistence/src/factories/token-store-factory.ts index c4696bb3..34f166d4 100644 --- a/packages/persistence/src/factories/token-store-factory.ts +++ b/packages/persistence/src/factories/token-store-factory.ts @@ -16,6 +16,7 @@ import { RedisTokenStore } from '../stores/redis/redis-token-store.js'; import { TokenEncryptionService } from '../encryption/token-encryption-service.js'; import { getSecretsProvider } from '@mcp-typescript-simple/config/secrets'; import { logger } from '../logger.js'; +import { getRedisKeyPrefix } from '../stores/redis/redis-utils.js'; export type TokenStoreType = 'memory' | 'file' | 'redis' | 'auto'; @@ -194,7 +195,9 @@ export class TokenStoreFactory { // Create encryption service const encryptionService = new TokenEncryptionService({ encryptionKey }); - return new RedisTokenStore(process.env.REDIS_URL, encryptionService); + const keyPrefix = getRedisKeyPrefix(); + + return new RedisTokenStore(process.env.REDIS_URL, encryptionService, keyPrefix); } /** diff --git a/packages/persistence/src/stores/redis/redis-client-store.ts b/packages/persistence/src/stores/redis/redis-client-store.ts index f7de4637..21db26a8 100644 --- a/packages/persistence/src/stores/redis/redis-client-store.ts +++ b/packages/persistence/src/stores/redis/redis-client-store.ts @@ -25,21 +25,27 @@ import { ClientStoreOptions, } from '../../interfaces/client-store.js'; import { logger } from '../../logger.js'; -import { maskRedisUrl } from './redis-utils.js'; - -const KEY_PREFIX = 'oauth:client:'; -const INDEX_KEY = 'oauth:clients:index'; +import { maskRedisUrl, normalizeKeyPrefix } from './redis-utils.js'; export class RedisClientStore implements OAuthRegisteredClientsStore { private redis: Redis; private options: ClientStoreOptions; + private readonly keyPrefix: string; + private readonly KEY_PREFIX: string; + private readonly INDEX_KEY: string; - constructor(redisUrl?: string, options: ClientStoreOptions = {}) { + constructor(redisUrl?: string, options: ClientStoreOptions = {}, keyPrefix: string = '') { const url = redisUrl ?? process.env.REDIS_URL; if (!url) { throw new Error('Redis URL not configured. Set REDIS_URL environment variable.'); } + // Store key prefix for multi-app isolation (empty string for backward compatibility) + // Auto-add colon separator if prefix provided (e.g., 'mcp-main' → 'mcp-main:') + this.keyPrefix = normalizeKeyPrefix(keyPrefix); + this.KEY_PREFIX = `${this.keyPrefix}oauth:client:`; + this.INDEX_KEY = `${this.keyPrefix}oauth:clients:index`; + this.redis = new Redis(url, { maxRetriesPerRequest: 3, retryStrategy: (times: number) => { @@ -81,7 +87,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { ): Promise { try { // Check max clients limit - const currentCount = await this.redis.scard(INDEX_KEY); + const currentCount = await this.redis.scard(this.INDEX_KEY); const maxClients = this.options.maxClients ?? 10000; if (currentCount >= maxClients) { logger.warn('Client registration failed: max clients limit reached', { @@ -116,7 +122,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { }; // Store in Redis - const key = `${KEY_PREFIX}${clientId}`; + const key = `${this.KEY_PREFIX}${clientId}`; if (expiresAt) { // Set with TTL (automatic expiration) @@ -128,7 +134,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { } // Add to index set for listing - await this.redis.sadd(INDEX_KEY, clientId); + await this.redis.sadd(this.INDEX_KEY, clientId); logger.info('Client registered in Redis', { clientId, @@ -147,7 +153,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { async getClient(clientId: string): Promise { try { - const key = `${KEY_PREFIX}${clientId}`; + const key = `${this.KEY_PREFIX}${clientId}`; const data = await this.redis.get(key); if (!data) { @@ -171,7 +177,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { async deleteClient(clientId: string): Promise { try { - const key = `${KEY_PREFIX}${clientId}`; + const key = `${this.KEY_PREFIX}${clientId}`; // Check if client exists const exists = await this.redis.exists(key); @@ -183,7 +189,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { // Delete from Redis and index await Promise.all([ this.redis.del(key), - this.redis.srem(INDEX_KEY, clientId), + this.redis.srem(this.INDEX_KEY, clientId), ]); logger.info('Client deleted from Redis', { clientId }); @@ -197,14 +203,14 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { async listClients(): Promise { try { // Get all client IDs from index - const clientIds = await this.redis.smembers(INDEX_KEY); + const clientIds = await this.redis.smembers(this.INDEX_KEY); if (clientIds?.length === 0) { return []; } // Fetch all clients in parallel - const keys = clientIds.map((id: string) => `${KEY_PREFIX}${id}`); + const keys = clientIds.map((id: string) => `${this.KEY_PREFIX}${id}`); const results = await this.redis.mget(...keys); // Filter out null values (expired clients) and parse @@ -224,7 +230,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { // Clean up expired client IDs from index if (expiredIds.length > 0) { - await this.redis.srem(INDEX_KEY, ...expiredIds); + await this.redis.srem(this.INDEX_KEY, ...expiredIds); logger.debug('Removed expired clients from index', { count: expiredIds.length, }); @@ -245,14 +251,14 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { async cleanupExpired(): Promise { try { // Get all client IDs from index - const clientIds = await this.redis.smembers(INDEX_KEY); + const clientIds = await this.redis.smembers(this.INDEX_KEY); if (clientIds?.length === 0) { return 0; } // Check which clients still exist (non-expired) - const keys = clientIds.map((id: string) => `${KEY_PREFIX}${id}`); + const keys = clientIds.map((id: string) => `${this.KEY_PREFIX}${id}`); const exists = await Promise.all(keys.map((key: string) => this.redis.exists(key))); // Find expired clients (in index but not in Redis) @@ -266,7 +272,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { // Remove expired client IDs from index if (expiredIds.length > 0) { - await this.redis.srem(INDEX_KEY, ...expiredIds); + await this.redis.srem(this.INDEX_KEY, ...expiredIds); logger.info('Expired clients cleaned up from Redis', { count: expiredIds.length, }); @@ -284,7 +290,7 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { */ async getClientCount(): Promise { try { - return await this.redis.scard(INDEX_KEY); + return await this.redis.scard(this.INDEX_KEY); } catch (error) { logger.error('Failed to get client count from Redis', error as Record); return 0; @@ -296,15 +302,15 @@ export class RedisClientStore implements OAuthRegisteredClientsStore { */ async clear(): Promise { try { - const clientIds = await this.redis.smembers(INDEX_KEY); + const clientIds = await this.redis.smembers(this.INDEX_KEY); if (clientIds?.length === 0) { return; } // Delete all client keys - const keys = clientIds.map((id: string) => `${KEY_PREFIX}${id}`); - await this.redis.del(...keys, INDEX_KEY); + const keys = clientIds.map((id: string) => `${this.KEY_PREFIX}${id}`); + await this.redis.del(...keys, this.INDEX_KEY); logger.warn('All clients cleared from Redis', { count: clientIds.length }); } catch (error) { diff --git a/packages/persistence/src/stores/redis/redis-mcp-metadata-store.ts b/packages/persistence/src/stores/redis/redis-mcp-metadata-store.ts index c194de52..23ef0615 100644 --- a/packages/persistence/src/stores/redis/redis-mcp-metadata-store.ts +++ b/packages/persistence/src/stores/redis/redis-mcp-metadata-store.ts @@ -23,15 +23,15 @@ import { } from '../../interfaces/mcp-metadata-store.js'; import { logger } from '../../logger.js'; import { TokenEncryptionService } from '../../encryption/token-encryption-service.js'; -import { maskRedisUrl, createRedisClient } from './redis-utils.js'; +import { maskRedisUrl, createRedisClient, normalizeKeyPrefix } from './redis-utils.js'; export class RedisMCPMetadataStore implements MCPSessionMetadataStore { private redis: Redis; private readonly encryptionService: TokenEncryptionService; - private readonly keyPrefix = 'mcp:session:'; + private readonly keyPrefix: string; private readonly DEFAULT_TTL = 30 * 60; // 30 minutes in seconds - constructor(redisUrl: string, encryptionService: TokenEncryptionService) { + constructor(redisUrl: string, encryptionService: TokenEncryptionService, keyPrefix: string = '') { // Enterprise security: encryption is MANDATORY if (!encryptionService) { throw new Error('TokenEncryptionService is REQUIRED. Encryption at rest is mandatory for SOC-2, ISO 27001, GDPR, HIPAA compliance.'); @@ -40,8 +40,12 @@ export class RedisMCPMetadataStore implements MCPSessionMetadataStore { this.encryptionService = encryptionService; this.redis = createRedisClient(redisUrl, 'MCP sessions'); + // Normalize key prefix (adds trailing colon if needed) + const normalized = normalizeKeyPrefix(keyPrefix); + this.keyPrefix = `${normalized}mcp:session:`; + const url = redisUrl ?? process.env.REDIS_URL ?? 'redis://localhost:6379'; - logger.info('RedisMCPMetadataStore initialized with encryption', { url: maskRedisUrl(url) }); + logger.info('RedisMCPMetadataStore initialized with encryption', { url: maskRedisUrl(url), keyPrefix: this.keyPrefix }); } /** diff --git a/packages/persistence/src/stores/redis/redis-oauth-token-store.ts b/packages/persistence/src/stores/redis/redis-oauth-token-store.ts index 0712b2a3..d17c09ba 100644 --- a/packages/persistence/src/stores/redis/redis-oauth-token-store.ts +++ b/packages/persistence/src/stores/redis/redis-oauth-token-store.ts @@ -30,16 +30,15 @@ import { OAuthTokenStore, serializeOAuthToken, deserializeOAuthToken } from '../ import { StoredTokenInfo } from '../../types.js'; import { logger } from '../../logger.js'; import { TokenEncryptionService } from '../../encryption/token-encryption-service.js'; -import { maskRedisUrl, createRedisClient } from './redis-utils.js'; - -const KEY_PREFIX = 'oauth:token:'; -const REFRESH_INDEX_PREFIX = 'oauth:refresh:'; +import { maskRedisUrl, createRedisClient, normalizeKeyPrefix } from './redis-utils.js'; export class RedisOAuthTokenStore implements OAuthTokenStore { private redis: Redis; private readonly encryptionService: TokenEncryptionService; + private readonly KEY_PREFIX: string; + private readonly REFRESH_INDEX_PREFIX: string; - constructor(redisUrl: string, encryptionService: TokenEncryptionService) { + constructor(redisUrl: string, encryptionService: TokenEncryptionService, keyPrefix: string = '') { // Enterprise security: encryption is MANDATORY if (!encryptionService) { throw new Error('TokenEncryptionService is REQUIRED. Encryption at rest is mandatory for SOC-2, ISO 27001, GDPR, HIPAA compliance.'); @@ -48,8 +47,13 @@ export class RedisOAuthTokenStore implements OAuthTokenStore { this.encryptionService = encryptionService; this.redis = createRedisClient(redisUrl, 'OAuth tokens'); + // Normalize key prefix (adds trailing colon if needed) + const normalized = normalizeKeyPrefix(keyPrefix); + this.KEY_PREFIX = `${normalized}oauth:token:`; + this.REFRESH_INDEX_PREFIX = `${normalized}oauth:refresh:`; + const url = redisUrl ?? process.env.REDIS_URL ?? 'redis://localhost:6379'; - logger.info('RedisOAuthTokenStore initialized', { url: maskRedisUrl(url) }); + logger.info('RedisOAuthTokenStore initialized', { url: maskRedisUrl(url), keyPrefix: this.KEY_PREFIX }); } /** @@ -61,7 +65,7 @@ export class RedisOAuthTokenStore implements OAuthTokenStore { */ private getTokenKey(accessToken: string): string { const hashedToken = this.encryptionService.hashKey(accessToken); - return `${KEY_PREFIX}${hashedToken}`; + return `${this.KEY_PREFIX}${hashedToken}`; } /** @@ -72,7 +76,7 @@ export class RedisOAuthTokenStore implements OAuthTokenStore { */ private getRefreshIndexKey(refreshToken: string): string { const hashedToken = this.encryptionService.hashKey(refreshToken); - return `${REFRESH_INDEX_PREFIX}${hashedToken}`; + return `${this.REFRESH_INDEX_PREFIX}${hashedToken}`; } async storeToken(accessToken: string, tokenInfo: StoredTokenInfo): Promise { @@ -227,7 +231,7 @@ export class RedisOAuthTokenStore implements OAuthTokenStore { let count = 0; do { - const result = await this.redis.scan(cursor, 'MATCH', `${KEY_PREFIX}*`, 'COUNT', 100); + const result = await this.redis.scan(cursor, 'MATCH', `${this.KEY_PREFIX}*`, 'COUNT', 100); cursor = result[0]; count += result[1].length; } while (cursor !== '0'); diff --git a/packages/persistence/src/stores/redis/redis-pkce-store.ts b/packages/persistence/src/stores/redis/redis-pkce-store.ts index 97a28868..d5378504 100644 --- a/packages/persistence/src/stores/redis/redis-pkce-store.ts +++ b/packages/persistence/src/stores/redis/redis-pkce-store.ts @@ -8,13 +8,14 @@ import { Redis } from 'ioredis'; import { PKCEStore, PKCEData } from '../../interfaces/pkce-store.js'; import { logger } from '../../logger.js'; +import { normalizeKeyPrefix } from './redis-utils.js'; export class RedisPKCEStore implements PKCEStore { - private readonly keyPrefix = 'oauth:pkce:'; + private readonly keyPrefix: string; private readonly defaultTTL = 600; // 10 minutes in seconds private redis: Redis; - constructor(redisUrl?: string) { + constructor(redisUrl?: string, keyPrefix: string = '') { const url = redisUrl ?? process.env.REDIS_URL; if (!url) { throw new Error('Redis URL not configured. Set REDIS_URL environment variable.'); @@ -30,12 +31,16 @@ export class RedisPKCEStore implements PKCEStore { lazyConnect: false, // Connect immediately to detect issues early }); + // Normalize key prefix (adds trailing colon if needed) + const normalized = normalizeKeyPrefix(keyPrefix); + this.keyPrefix = `${normalized}oauth:pkce:`; + this.redis.on('error', (error: Error) => { logger.error('Redis PKCE store error', error as unknown as Record); }); this.redis.on('connect', () => { - logger.info('Redis PKCE store connected'); + logger.info('Redis PKCE store connected', { keyPrefix: this.keyPrefix }); }); } diff --git a/packages/persistence/src/stores/redis/redis-session-store.ts b/packages/persistence/src/stores/redis/redis-session-store.ts index 11cf9c30..7c5b5ba0 100644 --- a/packages/persistence/src/stores/redis/redis-session-store.ts +++ b/packages/persistence/src/stores/redis/redis-session-store.ts @@ -18,29 +18,33 @@ import { Redis } from 'ioredis'; import { OAuthSessionStore } from '../../interfaces/session-store.js'; import { OAuthSession } from '../../types.js'; import { logger } from '../../logger.js'; -import { maskRedisUrl, createRedisClient } from './redis-utils.js'; +import { maskRedisUrl, createRedisClient, normalizeKeyPrefix } from './redis-utils.js'; /** - * Redis key prefix for namespacing + * Session timeout for OAuth flows */ -const KEY_PREFIX = 'oauth:session:'; const SESSION_TIMEOUT = 10 * 60; // 10 minutes in seconds export class RedisSessionStore implements OAuthSessionStore { private redis: Redis; + private readonly KEY_PREFIX: string; - constructor(redisUrl?: string) { + constructor(redisUrl?: string, keyPrefix: string = '') { this.redis = createRedisClient(redisUrl, 'OAuth sessions'); + // Normalize key prefix (adds trailing colon if needed) + const normalized = normalizeKeyPrefix(keyPrefix); + this.KEY_PREFIX = `${normalized}oauth:session:`; + const url = redisUrl ?? process.env.REDIS_URL ?? 'redis://localhost:6379'; - logger.info('RedisSessionStore initialized', { url: maskRedisUrl(url) }); + logger.info('RedisSessionStore initialized', { url: maskRedisUrl(url), keyPrefix: this.KEY_PREFIX }); } /** * Generate Redis key for session state */ private getSessionKey(state: string): string { - return `${KEY_PREFIX}${state}`; + return `${this.KEY_PREFIX}${state}`; } async storeSession(state: string, session: OAuthSession): Promise { @@ -132,7 +136,7 @@ export class RedisSessionStore implements OAuthSessionStore { async getSessionCount(): Promise { try { // Scan for all session keys - const keys = await this.redis.keys(`${KEY_PREFIX}*`); + const keys = await this.redis.keys(`${this.KEY_PREFIX}*`); return keys.length; } catch (error) { logger.error('Failed to get session count', { error }); diff --git a/packages/persistence/src/stores/redis/redis-token-store.ts b/packages/persistence/src/stores/redis/redis-token-store.ts index 73f06d9f..1d1608d0 100644 --- a/packages/persistence/src/stores/redis/redis-token-store.ts +++ b/packages/persistence/src/stores/redis/redis-token-store.ts @@ -38,20 +38,16 @@ import { } from '../../interfaces/token-store.js'; import { logger } from '../../logger.js'; import { TokenEncryptionService } from '../../encryption/token-encryption-service.js'; -import { maskRedisUrl } from './redis-utils.js'; - -/** - * Redis key prefixes for namespacing - */ -const KEY_PREFIX = 'dcr:token:'; -const VALUE_PREFIX = 'dcr:value:'; -const INDEX_KEY = 'dcr:tokens:all'; +import { maskRedisUrl, normalizeKeyPrefix } from './redis-utils.js'; export class RedisTokenStore implements InitialAccessTokenStore { private redis: Redis; private readonly encryptionService: TokenEncryptionService; + private readonly KEY_PREFIX: string; + private readonly VALUE_PREFIX: string; + private readonly INDEX_KEY: string; - constructor(redisUrl: string | undefined, encryptionService: TokenEncryptionService) { + constructor(redisUrl: string | undefined, encryptionService: TokenEncryptionService, keyPrefix: string = '') { const url = redisUrl ?? process.env.REDIS_URL; if (!url) { throw new Error('Redis URL not configured. Set REDIS_URL environment variable.'); @@ -70,12 +66,18 @@ export class RedisTokenStore implements InitialAccessTokenStore { lazyConnect: true, }); + // Normalize key prefix (adds trailing colon if needed) + const normalized = normalizeKeyPrefix(keyPrefix); + this.KEY_PREFIX = `${normalized}dcr:token:`; + this.VALUE_PREFIX = `${normalized}dcr:value:`; + this.INDEX_KEY = `${normalized}dcr:tokens:all`; + this.redis.on('error', (error: Error) => { logger.error('Redis connection error', { error }); }); this.redis.on('connect', () => { - logger.info('Redis connected successfully for DCR tokens'); + logger.info('Redis connected successfully for DCR tokens', { keyPrefix: this.KEY_PREFIX }); }); // Connect immediately @@ -89,7 +91,8 @@ export class RedisTokenStore implements InitialAccessTokenStore { logger.info('RedisTokenStore initialized', { url: maskRedisUrl(url), - encryption: 'enabled (required)' + encryption: 'enabled (required)', + keyPrefix: this.KEY_PREFIX }); } @@ -127,7 +130,7 @@ export class RedisTokenStore implements InitialAccessTokenStore { * NOTE: IDs are UUIDs (not sensitive), but we hash for consistency */ private getTokenKey(id: string): string { - return `${KEY_PREFIX}${id}`; + return `${this.KEY_PREFIX}${id}`; } /** @@ -139,7 +142,7 @@ export class RedisTokenStore implements InitialAccessTokenStore { */ private getValueKey(token: string): string { const hashedToken = this.encryptionService.hashKey(token); - return `${VALUE_PREFIX}${hashedToken}`; + return `${this.VALUE_PREFIX}${hashedToken}`; } async createToken(options: CreateTokenOptions): Promise { @@ -166,7 +169,7 @@ export class RedisTokenStore implements InitialAccessTokenStore { } // Add to index (for listing) - await this.redis.sadd(INDEX_KEY, tokenData.id); + await this.redis.sadd(this.INDEX_KEY, tokenData.id); logger.info('Initial access token created in Redis', { tokenId: tokenData.id, @@ -259,7 +262,7 @@ export class RedisTokenStore implements InitialAccessTokenStore { includeExpired?: boolean; }): Promise { // Get all token IDs from index - const ids = await this.redis.smembers(INDEX_KEY); + const ids = await this.redis.smembers(this.INDEX_KEY); if (ids.length === 0) { return []; @@ -303,7 +306,7 @@ export class RedisTokenStore implements InitialAccessTokenStore { await this.redis.del(valueKey); // Remove from index - await this.redis.srem(INDEX_KEY, id); + await this.redis.srem(this.INDEX_KEY, id); logger.info('Token deleted', { tokenId: id }); return true; diff --git a/packages/persistence/src/stores/redis/redis-utils.ts b/packages/persistence/src/stores/redis/redis-utils.ts index 1a48d79c..8d73b8ae 100644 --- a/packages/persistence/src/stores/redis/redis-utils.ts +++ b/packages/persistence/src/stores/redis/redis-utils.ts @@ -8,6 +8,33 @@ import { Redis } from 'ioredis'; import { logger } from '../../logger.js'; +/** + * Get Redis key prefix from environment variable + * + * @returns Key prefix from REDIS_KEY_PREFIX env var (empty string if not set) + */ +export function getRedisKeyPrefix(): string { + return process.env.REDIS_KEY_PREFIX ?? ''; +} + +/** + * Normalize Redis key prefix by ensuring it ends with a colon separator + * + * Converts: + * - 'mcp-main' → 'mcp-main:' + * - 'mcp-main:' → 'mcp-main:' (no change) + * - '' → '' (empty string stays empty for backward compatibility) + * + * @param prefix User-provided key prefix (may or may not include trailing colon) + * @returns Normalized prefix with trailing colon (or empty string if no prefix) + */ +export function normalizeKeyPrefix(prefix: string): string { + if (!prefix) { + return ''; // Empty prefix for backward compatibility + } + return prefix.endsWith(':') ? prefix : `${prefix}:`; +} + /** * Mask sensitive parts of Redis URL for logging *