Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/config/src/storage-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down
6 changes: 4 additions & 2 deletions packages/create-mcp-typescript-simple/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,17 @@ function generatePackageJson(config: ProjectConfig): object {
*/
export async function generateProject(config: ProjectConfig, targetDir: string): Promise<void> {
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
Expand Down
97 changes: 72 additions & 25 deletions packages/create-mcp-typescript-simple/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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';
Expand Down Expand Up @@ -53,6 +54,61 @@
}
}

/**
* 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<ProjectConfig> {
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
*/
Expand Down Expand Up @@ -98,40 +154,31 @@
.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) {

Check warning on line 171 in packages/create-mcp-typescript-simple/src/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=jdutton_mcp-typescript-simple&issues=AZrNXvhZJ6Rww_VNwl7p&open=AZrNXvhZJ6Rww_VNwl7p&pullRequest=106
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`));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/create-mcp-typescript-simple/templates/env.local.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions packages/create-mcp-typescript-simple/templates/env.oauth.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,13 @@ export default async function globalSetup(): Promise<void> {
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,
Expand Down
16 changes: 3 additions & 13 deletions packages/create-mcp-typescript-simple/templates/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';

export default defineConfig({
test: {
Expand All @@ -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
});
Original file line number Diff line number Diff line change
Expand Up @@ -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/**',
Expand Down
5 changes: 4 additions & 1 deletion packages/persistence/src/factories/client-store-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading