CLAUDE.md•20.2 kB
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **timezone MCP server** built with NestJS and TypeScript that provides:
1. **MCP (Model Context Protocol)** endpoints - For LLM clients like Claude Desktop to auto-discover and use timezone tools and prompts (authenticated via OAuth)
2. **REST API** (development only) - Traditional HTTP endpoints with Swagger/OpenAPI documentation (unauthenticated, for testing)
The server provides timezone operations (get regions, cities, current time) with ISO 8601 formatted timestamps. It exposes both **MCP tools** (callable functions) and **MCP prompts** (pre-built conversation starters) to guide LLM interactions.
**Key Architecture Decisions:**
- **Production**: Only MCP endpoints available, protected by Google OAuth + email allowlist
- **Development**: MCP endpoints (authenticated) + REST API and Swagger (unauthenticated for easy testing)
## Development Commands
### Running the Server
The application has **two distinct runtime modes** controlled by the `MCP_TRANSPORT` environment variable, and behavior changes based on `NODE_ENV`:
1. **HTTP mode - Development** (`MCP_TRANSPORT=http`, `NODE_ENV=development`):
```bash
NODE_ENV=development pnpm dev # Development with hot reload
NODE_ENV=development pnpm start # Development mode
```
- Starts HTTP server on port 3000
- Enables REST API with Swagger UI at `/api` (unauthenticated)
- Enables MCP endpoint at `/mcp` (authenticated via Google OAuth + email allowlist)
- Health check at `/health` (public)
- MCP auth endpoints at `/auth/mcp/*`
2. **HTTP mode - Production** (`MCP_TRANSPORT=http`, `NODE_ENV=production`):
```bash
NODE_ENV=production pnpm start:prod # Production (compiled)
```
- Starts HTTP server on port 3000
- **Only** MCP endpoint at `/mcp` (authenticated via Google OAuth + email allowlist)
- Health check at `/health` (public)
- MCP auth endpoints at `/auth/mcp/*`
- **REST API and Swagger disabled**
3. **stdio mode** (`MCP_TRANSPORT=stdio`):
```bash
MCP_TRANSPORT=stdio pnpm start
```
- No HTTP server - pure stdio JSON-RPC communication
- Disables all logging to keep stdout clean
- Used by Claude Desktop when configured as subprocess
- No authentication in this mode
### Testing
```bash
pnpm test # Run all tests (unit + e2e)
pnpm test:watch # Watch mode
pnpm test:cov # With coverage report
pnpm test:ui # Vitest UI
pnpm test:e2e # Only e2e tests
pnpm typecheck # TypeScript type checking
```
**Single test file:**
```bash
pnpm vitest src/auth/auth.service.spec.ts
```
**Single test case:**
```bash
pnpm vitest -t "should generate JWT token"
```
### Code Quality
```bash
pnpm lint # ESLint check
pnpm lint:fix # Auto-fix linting issues
pnpm format # Format with Prettier
pnpm format:check # Check formatting
```
### Build & Deployment
```bash
pnpm build # Compile TypeScript to dist/
pnpm deploy # Deploy to Google Cloud Run (uses scripts/deploy.sh)
pnpm clean # Remove node_modules, dist, coverage
```
### MCP Testing Tools
```bash
pnpm mcp-inspector:stdio # Test MCP stdio transport locally
pnpm mcp-inspector:http # Test MCP HTTP transport (requires server running on :3000)
pnpm mcp-inspector:http:prod # Test deployed MCP endpoint
```
### Utilities
```bash
pnpm swagger # Open Swagger UI in browser
pnpm swagger:prod # Open deployed Swagger UI
pnpm deps:check # Check for dependency updates
pnpm deps:update # Update to latest minor/patch versions
pnpm deps:update:major # Update including major versions
```
## Architecture
### Multi-Mode Bootstrap
The application uses a **conditional bootstrap strategy** in `src/main.test-helper.ts`:
- **stdio mode**: Creates `ApplicationContext` (no HTTP server, logging disabled, no auth)
- **HTTP mode (development)**: Creates NestJS app with REST API, Swagger UI, MCP endpoint with auth
- **HTTP mode (production)**: Creates NestJS app with ONLY MCP endpoint (authenticated), health check, and auth endpoints - REST API and Swagger are disabled
The actual entry point (`src/main.ts`) delegates to `bootstrap()` from `main.test-helper.ts` for testability.
The `TimezoneModule` uses a **dynamic module pattern** (`forRoot()`) to conditionally register the REST controller based on `NODE_ENV`, while always providing `TimezoneService` for MCP tools.
### Multi-Mode Architecture Pattern
```
┌────────────────────────────────────────────────────────────────────┐
│ Timezone MCP Server │
├────────────────────────────────────────────────────────────────────┤
│ │
│ Development Production stdio Mode │
│ (NODE_ENV=dev) (NODE_ENV=prod) (MCP_TRANSPORT=stdio) │
│ ↓ ↓ ↓ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────┐ │
│ │ NestJS │ │ NestJS App │ │ MCP │ │
│ │┌────────┐│ │┌────────────┐│ │ stdio │ │
│ ││REST API││ ││/health ││ │ (JSON- │ │
│ ││+Swagger││ ││/auth/mcp/* ││ │ RPC) │ │
│ │└────────┘│ │└────────────┘│ └────┬────┘ │
│ │┌────────┐│ │┌────────────┐│ │ │
│ ││ /mcp ││ ││ /mcp ││ │ │
│ ││ (auth) ││ ││ (auth) ││ │ │
│ │└────────┘│ │└────────────┘│ │ │
│ └────┬─────┘ └──────┬───────┘ │ │
│ │ │ │ │
│ └───────────────────┴─────────────────────┘ │
│ ↓ │
│ TimezoneService │
│ (Shared Business Logic) │
└───────────────────────────────────────────────────────────────────┘
```
**Key insight**: All three modes share the same `TimezoneService`, ensuring consistent behavior. The MCP tools (`src/mcp-tools/tools/*.tool.ts`) wrap `TimezoneService` methods, and MCP prompts (`src/mcp-tools/prompts/*.prompt.ts`) provide conversation templates. Both are auto-discovered by `@rekog/mcp-nest`.
- **Development**: Full feature set - REST API (unauthenticated) + MCP endpoint (authenticated)
- **Production**: Security-focused - Only MCP endpoint (authenticated), REST API disabled
- **stdio**: Claude Desktop integration - No HTTP, pure MCP protocol, no auth
### Module Structure
- **AppModule** - Root module, imports all others, configures MCP transport and authentication
- Imports `McpAuthModule` from `@rekog/mcp-nest` for OAuth authentication
- Configures MCP module with `EmailAllowlistGuard` to protect MCP endpoints
- Conditionally imports `TimezoneModule.forRoot()` with dynamic controller registration
- **MCP Auth Components** (in `src/mcp-auth/`) - Email allowlist validation for MCP authentication
- `AllowlistService` - Checks if user email is in `ALLOWED_EMAILS` env var
- `EmailAllowlistGuard` - Extends `McpAuthJwtGuard` from `@rekog/mcp-nest`, adds email validation
- Only protects MCP endpoints (`/mcp`), REST API is unauthenticated (dev only)
- **TimezoneModule** - Core timezone business logic (uses dynamic module pattern)
- `TimezoneService` - Uses `Intl.supportedValuesOf('timeZone')` (always provided)
- `TimezoneController` - REST endpoints (only registered when `NODE_ENV=development`)
- **McpToolsModule** - MCP tool and prompt definitions
- Tools:
- `GetRegionsTool` - Wraps `timezoneService.getRegions()`
- `GetCitiesTool` - Wraps `timezoneService.getCitiesInRegion()`
- `GetTimezoneInfoTool` - Wraps `timezoneService.getTimeInTimezone()`
- Prompts:
- `ExploreTimezonesPrompt` - Guides users to discover regions and cities
- `GetCurrentTimePrompt` - Helps get current time in a timezone
- `CompareTimezonesPrompt` - Guides timezone comparison
### MCP Tool Pattern
MCP tools use the `@Tool()` decorator from `@rekog/mcp-nest`:
```typescript
@Injectable()
export class GetRegionsTool {
constructor(private readonly timezoneService: TimezoneService) {}
@Tool({
name: 'get_regions',
description: 'Get a list of all available timezone regions',
parameters: z.object({}), // Zod schema for validation
})
async execute() {
const regions = this.timezoneService.getRegions()
return {
content: [{ type: 'text', text: JSON.stringify({ regions, count: regions.length }) }],
}
}
}
```
Tools are automatically registered with MCP server and exposed via JSON-RPC `tools/list` and `tools/call`.
### MCP Prompt Pattern
MCP prompts use the `@Prompt()` decorator from `@rekog/mcp-nest` to provide pre-built conversation starters for LLM clients:
```typescript
@Injectable()
export class GetCurrentTimePrompt {
@Prompt({
name: 'get_current_time',
description: 'Get guidance on checking the current time in a specific timezone',
parameters: z.object({
timezone: z.string().describe('The IANA timezone identifier'),
}),
})
async execute({ timezone }: { timezone: string }) {
return {
description: `Guidance for getting current time in ${timezone}`,
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `What is the current time in ${timezone}?`,
},
},
{
role: 'assistant' as const,
content: {
type: 'text' as const,
text: `I'll get the current time for ${timezone} using the get_timezone_info tool.`,
},
},
],
}
}
}
```
Prompts are automatically registered with MCP server and exposed via JSON-RPC `prompts/list` and `prompts/get`.
### Authentication Architecture
**MCP Authentication** (via `@rekog/mcp-nest`):
- Uses `McpAuthModule` from `@rekog/mcp-nest` package
- Provides OAuth2 authentication specifically designed for MCP protocol
- Currently configured with Google OAuth provider
- Authentication endpoints at `/auth/mcp/*` (e.g., `/auth/mcp/authorize`, `/auth/mcp/callback`, `/auth/mcp/token`)
**OAuth2 Flow**:
1. MCP client initiates OAuth flow via `/auth/mcp/authorize`
2. Redirects to Google OAuth provider
3. After authentication, callback at `/auth/mcp/callback`
4. `McpAuthModule` generates JWT access token
5. Client uses token in MCP requests
**Email Allowlist Protection**:
- Custom `EmailAllowlistGuard` extends `McpAuthJwtGuard` from `@rekog/mcp-nest`
- After JWT validation, checks if user's email is in `ALLOWED_EMAILS` env var
- Only applied to MCP endpoints via `McpModule.forRoot({ guards: [EmailAllowlistGuard] })`
- **REST API (development only) is unauthenticated** for easy testing
- **stdio mode has NO authentication** (direct integration with Claude Desktop)
**Security Model**:
- **Production**: Only MCP endpoints exist, all authenticated with OAuth + email allowlist
- **Development**: MCP endpoints authenticated, REST API unauthenticated (testing convenience)
- **stdio mode**: No authentication (trusted local process)
## Environment Configuration
Copy `example.env` to `.env` and configure:
**Required**:
- `NODE_ENV` - `development` or `production` (controls REST API availability)
- `MCP_AUTH_CLIENT_ID` - Google OAuth client ID
- `MCP_AUTH_CLIENT_SECRET` - Google OAuth client secret
- `MCP_AUTH_JWT_SECRET` - Secret key for signing MCP JWT tokens
- `MCP_AUTH_SERVER_URL` - Base URL for OAuth callbacks (e.g., `http://localhost:3000` or production URL)
- `ALLOWED_EMAILS` - Comma-separated list of allowed email addresses for MCP access
**Optional**:
- `PORT` - HTTP server port (default: 3000)
- `MCP_TRANSPORT` - `stdio` or `http` (default: http)
- `JWT_EXPIRES_IN` - Token expiration (default: 3600, can use `3600s`, `1h`, `1d`)
- `GCLOUD_PROJECT_ID`, `GCLOUD_REGION` - For Google Cloud deployment
**Note**: The file is named `example.env` (not `.env.example`) for consistent syntax highlighting.
**Runtime Modes**:
- **Development** (`NODE_ENV=development`): REST API + Swagger (unauthenticated) + MCP endpoint (authenticated)
- **Production** (`NODE_ENV=production`): Only MCP endpoint (authenticated) + health check
- **stdio mode** (`MCP_TRANSPORT=stdio`): No HTTP server, pure MCP stdio communication, no auth
## Testing Strategy
### Test File Organization
- **Unit tests**: `*.spec.ts` files next to source files
- **E2E tests**: `test/*.e2e.spec.ts` directory
- **Test helpers**:
- `test/test-config.helper.ts` - Mock `ConfigService` for E2E tests
- `src/main.test-helper.ts` - Exports `bootstrap()` for testing entry point
### ConfigService Mocking
Due to `ConfigModule.forRoot({ isGlobal: true })`, some E2E tests override the provider:
```typescript
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(ConfigService)
.useValue(createMockConfigService())
.compile()
```
### Known Test Issues
Some E2E tests fail due to ConfigModule global registration preventing proper mocking. These are pre-existing issues that don't affect production functionality.
## Git Hooks
- **Pre-commit** (Husky + lint-staged): ESLint + Prettier on staged `*.ts` files
- **Pre-push**: Runs `pnpm lint`, `pnpm typecheck`, `pnpm test:cov`
## Package Manager
**pnpm only** - Enforced via `packageManager` field in `package.json` and Corepack. The exact version (10.18.3) is locked.
Node.js version managed via `.node-version` file (requires fnm or nvm).
## Adding New MCP Tools
1. Create tool file in `src/mcp-tools/tools/my-tool.tool.ts`
2. Use `@Tool()` decorator with Zod schema for parameters
3. Inject `TimezoneService` or other dependencies via constructor
4. Return `{ content: [{ type: 'text', text: JSON.stringify(result) }] }`
5. Add to `McpToolsModule` providers and exports
6. Create corresponding `*.spec.ts` unit test
## Adding New MCP Prompts
1. Create prompt file in `src/mcp-tools/prompts/my-prompt.prompt.ts`
2. Use `@Prompt()` decorator with:
- `name` - Unique identifier (e.g., "my_prompt")
- `description` - What guidance this prompt provides
- `parameters` - Zod schema with string types only (all optional or required)
3. Return object with:
- `description` (optional) - Contextual description
- `messages` - Array of user/assistant message exchanges
4. Each message must have:
- `role`: `'user'` or `'assistant'`
- `content`: `{ type: 'text', text: 'message content' }`
5. Add to `McpToolsModule` providers and exports
6. Create corresponding `*.spec.ts` unit test
**Example prompts in this project:**
- `ExploreTimezonesPrompt` - No parameters, guides general exploration
- `GetCurrentTimePrompt` - Single `timezone` parameter
- `CompareTimezonesPrompt` - Two parameters (`timezone1`, `timezone2`)
## Customizing MCP Authentication
The project uses `@rekog/mcp-nest`'s built-in authentication system with Google OAuth. To customize:
**Change OAuth Provider**:
1. In `src/app/app.module.ts`, replace `GoogleOAuthProvider` with `GitHubOAuthProvider` or `AzureADOAuthProvider`
2. Update environment variables in `example.env` and deployment config
3. Update Google Cloud secrets if deploying to Cloud Run
**Customize Email Allowlist**:
- Modify `src/mcp-auth/allowlist.service.ts` to implement different access control (e.g., role-based)
- Update `src/mcp-auth/email-allowlist.guard.ts` validation logic
- Keep the guard registered in `McpModule.forRoot({ guards: [...] })`
**Disable Authentication** (not recommended for production):
- Remove `EmailAllowlistGuard` from `McpModule.forRoot({ guards: [] })`
- Remove `McpAuthModule` import from `AppModule`
**Note**: The REST API in development mode is intentionally unauthenticated for ease of testing. Only MCP endpoints require authentication.
## Deployment
The project deploys to Google Cloud Run via `scripts/deploy.sh`.
### Prerequisites
**Before first deployment**, you must set up secrets in Google Cloud Secret Manager:
```bash
# 1. Enable Secret Manager API (first time only)
gcloud services enable secretmanager.googleapis.com
# 2. Create required secrets for MCP authentication
echo -n "$(openssl rand -hex 32)" | gcloud secrets create mcp-auth-jwt-secret --data-file=-
echo -n "YOUR_GOOGLE_CLIENT_ID" | gcloud secrets create mcp-auth-client-id --data-file=-
echo -n "YOUR_GOOGLE_CLIENT_SECRET" | gcloud secrets create mcp-auth-client-secret --data-file=-
echo -n "user@example.com,admin@example.com" | gcloud secrets create allowed-emails --data-file=-
```
**Get Google OAuth credentials**:
1. Go to [Google Cloud Console → APIs & Credentials](https://console.cloud.google.com/apis/credentials)
2. Create OAuth 2.0 Client ID
3. Add authorized redirect URI: `https://your-service.a.run.app/auth/mcp/callback`
4. Copy Client ID and Client Secret to secrets
### Deployment Process
The deployment script:
- Builds Docker image
- Pushes to Google Artifact Registry
- Deploys to Cloud Run with:
- **Non-sensitive env vars** set in `cloudbuild.yaml`:
- `NODE_ENV=production` (disables REST API and Swagger)
- `MCP_TRANSPORT=http`
- `JWT_EXPIRES_IN=3600`
- `MCP_AUTH_SERVER_URL` (production callback base URL)
- **Sensitive values** from Google Secret Manager:
- `MCP_AUTH_CLIENT_ID` (Google OAuth client ID)
- `MCP_AUTH_CLIENT_SECRET` (Google OAuth client secret)
- `MCP_AUTH_JWT_SECRET` (JWT signing secret)
- `ALLOWED_EMAILS` (comma-separated email allowlist)
- Saves production URL to `latest-prod-url.txt`
**Important**: In production (`NODE_ENV=production`), only the following endpoints are available:
- `/health` - Health check (public)
- `/mcp` - MCP endpoint (authenticated)
- `/auth/mcp/*` - OAuth authentication endpoints
- **REST API and Swagger are disabled**
### Configuration Strategy
**Environment-specific values:**
- **Local (.env)**:
- `NODE_ENV=development` (enables REST API + Swagger)
- `MCP_AUTH_SERVER_URL=http://localhost:3000`
- **Production (cloudbuild.yaml)**:
- `NODE_ENV=production` (disables REST API + Swagger)
- `MCP_AUTH_SERVER_URL=https://your-service.a.run.app`
**OAuth callback URLs**:
- Local: `http://localhost:3000/auth/mcp/callback`
- Production: `https://your-service.a.run.app/auth/mcp/callback`
- Both must be registered in Google Cloud Console OAuth credentials
**Security model:**
- Non-sensitive config → `cloudbuild.yaml` (e.g., `NODE_ENV`, `MCP_TRANSPORT`, `MCP_AUTH_SERVER_URL`)
- Sensitive secrets → Google Secret Manager (e.g., `MCP_AUTH_JWT_SECRET`, `MCP_AUTH_CLIENT_SECRET`, `ALLOWED_EMAILS`)