-
Notifications
You must be signed in to change notification settings - Fork 0
Feature: staging #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: staging #16
Changes from 2 commits
af2a568
2f0a0e8
c4c35cb
7085319
2b0a7df
4ef57c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| /** | ||
| * Injection token for the external alias Redis client. | ||
| * Separate from REDIS_CLIENT (session store) to keep concerns isolated. | ||
| */ | ||
| export const ALIAS_REDIS_CLIENT = 'ALIAS_REDIS_CLIENT'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { Module, Global, Logger, OnModuleDestroy, Inject } from '@nestjs/common'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import Redis from 'ioredis'; | ||
| import { ALIAS_REDIS_CLIENT } from './alias.constants'; | ||
| import { AliasService } from './alias.service'; | ||
|
|
||
| const logger = new Logger('AliasRedisModule'); | ||
|
|
||
| /** | ||
| * AliasModule | ||
| * | ||
| * Provides a dedicated ioredis connection to the external alias Redis instance. | ||
| * Kept separate from RedisModule (session store) so the two concerns never share | ||
| * a connection and can be configured / replaced independently. | ||
| * | ||
| * Key format stored by the dashboard: | ||
| * {ALIAS_REDIS_KEY_PREFIX}:{alias} → projectApiKey (plain string) | ||
| */ | ||
| @Global() | ||
| @Module({ | ||
| providers: [ | ||
| { | ||
| provide: ALIAS_REDIS_CLIENT, | ||
| useFactory: (configService: ConfigService): Redis => { | ||
| const client = new Redis({ | ||
| host: configService.get<string>('ALIAS_REDIS_HOST', 'localhost'), | ||
| port: configService.get<number>('ALIAS_REDIS_PORT', 6379), | ||
| password: configService.get<string>('ALIAS_REDIS_PASSWORD') || undefined, | ||
| db: configService.get<number>('ALIAS_REDIS_DB', 0), | ||
| retryStrategy: (times: number) => { | ||
| if (times > 3) { | ||
| logger.error(`Alias Redis connection failed after ${times} attempts. Giving up.`); | ||
| return null; | ||
| } | ||
| const delay = Math.min(times * 500, 3000); | ||
| logger.warn( | ||
| `Alias Redis connection attempt ${times} failed. Retrying in ${delay}ms...`, | ||
| ); | ||
| return delay; | ||
| }, | ||
| lazyConnect: false, | ||
| }); | ||
|
|
||
| client.on('connect', () => { | ||
| logger.log('Alias Redis client connected'); | ||
| }); | ||
|
|
||
| client.on('error', (err: Error) => { | ||
| logger.error(`Alias Redis client error: ${err.message}`); | ||
| }); | ||
|
|
||
| client.on('close', () => { | ||
| logger.warn('Alias Redis client connection closed'); | ||
| }); | ||
|
|
||
| return client; | ||
| }, | ||
| inject: [ConfigService], | ||
| }, | ||
| AliasService, | ||
| ], | ||
| exports: [AliasService], | ||
| }) | ||
| export class AliasModule implements OnModuleDestroy { | ||
| constructor( | ||
| @Inject(ALIAS_REDIS_CLIENT) | ||
| private readonly redis: Redis, | ||
| ) {} | ||
|
|
||
| async onModuleDestroy(): Promise<void> { | ||
| try { | ||
| await this.redis.quit(); | ||
| logger.log('Alias Redis client disconnected gracefully'); | ||
| } catch (err) { | ||
| logger.error(`Error disconnecting Alias Redis: ${(err as Error).message}`); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { Injectable, Inject, Logger } from '@nestjs/common'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import Redis from 'ioredis'; | ||
| import { ALIAS_REDIS_CLIENT } from './alias.constants'; | ||
|
|
||
| /** | ||
| * AliasService | ||
| * | ||
| * Resolves partner aliases to their underlying project API keys. | ||
| * Partners register via the dashboard which writes: | ||
| * {ALIAS_REDIS_KEY_PREFIX}:{alias} → projectApiKey | ||
| * into the external Redis instance. | ||
| * | ||
| * The key prefix is env-configurable so staging and production can share | ||
| * the same Redis while using different namespaces (e.g. "alias-staging:" vs "alias:"). | ||
| */ | ||
| @Injectable() | ||
| export class AliasService { | ||
| private readonly logger = new Logger(AliasService.name); | ||
|
|
||
| constructor( | ||
| @Inject(ALIAS_REDIS_CLIENT) private readonly redis: Redis, | ||
| private readonly configService: ConfigService, | ||
| ) {} | ||
|
|
||
| /** | ||
| * Resolves an alias to the actual project API key. | ||
| * Returns null if the alias is not registered. | ||
| */ | ||
| async resolveAlias(alias: string): Promise<string | null> { | ||
| const prefix = this.configService.get<string>('ALIAS_REDIS_KEY_PREFIX', 'alias'); | ||
| const key = `${prefix}:${alias}`; | ||
|
|
||
| const apiKey = await this.redis.get(key); | ||
|
|
||
| if (!apiKey) { | ||
| this.logger.warn(`Alias not found: ${alias} (key: ${key})`); | ||
| return null; | ||
| } | ||
|
|
||
| this.logger.debug(`Alias resolved: ${alias} → ${apiKey}`); | ||
| return apiKey; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ import { SessionManagerService } from './services/session-manager.service'; | |
| import { McpServerFactory } from './services/mcp-server.factory'; | ||
| import { decodeJwt } from '../common/utils/jwt.utils'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import { AliasService } from '../alias/alias.service'; | ||
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; | ||
| import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; | ||
| import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; | ||
|
|
@@ -47,8 +48,33 @@ export class McpController { | |
| private readonly sessionManager: SessionManagerService, | ||
| private readonly serverFactory: McpServerFactory, | ||
| private readonly configService: ConfigService, | ||
| private readonly aliasService: AliasService, | ||
| ) {} | ||
|
|
||
| /** | ||
| * Resolves a partner alias to the actual project API key. | ||
| * Sends a 404 JSON-RPC error response and returns null when the alias is unknown. | ||
| */ | ||
| private async resolveAlias( | ||
| alias: string, | ||
| body: unknown, | ||
| res: Response, | ||
| ): Promise<string | null> { | ||
| const apiKey = await this.aliasService.resolveAlias(alias); | ||
| if (!apiKey) { | ||
| res.status(HttpStatus.NOT_FOUND).json({ | ||
| jsonrpc: '2.0', | ||
| error: { | ||
| code: -32004, | ||
| message: `Not Found: Unknown alias '${alias}'`, | ||
| }, | ||
| id: (body as Record<string, unknown>)?.id || null, | ||
| }); | ||
| return null; | ||
| } | ||
| return apiKey; | ||
|
||
| } | ||
|
|
||
| /** | ||
| * Validates Bearer token and returns AuthInfo + userId. | ||
| * Sends 401 response and returns null on failure. | ||
|
|
@@ -112,28 +138,33 @@ export class McpController { | |
| } | ||
|
|
||
| /** | ||
| * POST /mcp/:projectApiKey | ||
| * POST /mcp/:alias | ||
| * Handles MCP JSON-RPC 2.0 requests via StreamableHTTPServerTransport. | ||
| * Creates a new transport + server on initialize requests, reuses existing for subsequent. | ||
| */ | ||
| @Post(':projectApiKey') | ||
| @Post(':alias') | ||
| @ApiOperation({ | ||
| summary: 'Handle MCP protocol request', | ||
| description: 'Processes MCP JSON-RPC 2.0 requests via Streamable HTTP transport', | ||
| }) | ||
| @ApiResponse({ status: 200, description: 'MCP response (streamed via SSE or JSON)' }) | ||
| @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid Bearer token' }) | ||
| @ApiResponse({ status: 404, description: 'Not Found - alias not registered' }) | ||
| async handleMcpPost( | ||
| @Param('projectApiKey') projectApiKey: string, | ||
| @Param('alias') alias: string, | ||
| @Headers('authorization') authorization: string | undefined, | ||
| @Headers('mcp-session-id') mcpSessionId: string | undefined, | ||
| @Req() req: Request, | ||
| @Res() res: Response, | ||
| ): Promise<void> { | ||
| this.logger.log( | ||
| `MCP POST received - Project: ${projectApiKey}, Method: ${req.body?.method || 'unknown'}, SessionId: ${mcpSessionId || 'none'}`, | ||
| `MCP POST received - Alias: ${alias}, Method: ${req.body?.method || 'unknown'}, SessionId: ${mcpSessionId || 'none'}`, | ||
| ); | ||
|
|
||
| // Resolve alias → actual project API key | ||
| const projectApiKey = await this.resolveAlias(alias, req.body, res); | ||
| if (!projectApiKey) return; | ||
|
|
||
| // Validate auth | ||
| const authResult = this.validateAuth(authorization, projectApiKey, req.body, res); | ||
|
||
| if (!authResult) return; | ||
|
|
@@ -211,26 +242,31 @@ export class McpController { | |
| } | ||
|
|
||
| /** | ||
| * GET /mcp/:projectApiKey | ||
| * GET /mcp/:alias | ||
| * SSE stream for server-initiated messages (notifications, etc.) | ||
| */ | ||
| @Get(':projectApiKey') | ||
| @Get(':alias') | ||
| @ApiOperation({ | ||
| summary: 'MCP SSE stream', | ||
| description: 'Server-Sent Events stream for server-initiated MCP messages', | ||
| }) | ||
| @ApiResponse({ status: 200, description: 'SSE stream established' }) | ||
| @ApiResponse({ status: 404, description: 'Not Found - alias not registered' }) | ||
| async handleMcpGet( | ||
| @Param('projectApiKey') projectApiKey: string, | ||
| @Param('alias') alias: string, | ||
| @Headers('authorization') authorization: string | undefined, | ||
| @Headers('mcp-session-id') mcpSessionId: string | undefined, | ||
| @Req() req: Request, | ||
| @Res() res: Response, | ||
| ): Promise<void> { | ||
| this.logger.log( | ||
| `MCP GET received - Project: ${projectApiKey}, SessionId: ${mcpSessionId || 'none'}`, | ||
| `MCP GET received - Alias: ${alias}, SessionId: ${mcpSessionId || 'none'}`, | ||
| ); | ||
|
|
||
| // Resolve alias → actual project API key | ||
| const projectApiKey = await this.resolveAlias(alias, undefined, res); | ||
| if (!projectApiKey) return; | ||
|
|
||
| // Validate auth | ||
| const authResult = this.validateAuth(authorization, projectApiKey, undefined, res); | ||
| if (!authResult) return; | ||
|
|
@@ -257,26 +293,31 @@ export class McpController { | |
| } | ||
|
|
||
| /** | ||
| * DELETE /mcp/:projectApiKey | ||
| * DELETE /mcp/:alias | ||
| * Terminates an MCP session. | ||
| */ | ||
| @Delete(':projectApiKey') | ||
| @Delete(':alias') | ||
| @ApiOperation({ | ||
| summary: 'Terminate MCP session', | ||
| description: 'Closes an active MCP session and cleans up resources', | ||
| }) | ||
| @ApiResponse({ status: 200, description: 'Session terminated' }) | ||
| @ApiResponse({ status: 404, description: 'Not Found - alias not registered' }) | ||
| async handleMcpDelete( | ||
| @Param('projectApiKey') projectApiKey: string, | ||
| @Param('alias') alias: string, | ||
| @Headers('authorization') authorization: string | undefined, | ||
| @Headers('mcp-session-id') mcpSessionId: string | undefined, | ||
| @Req() req: Request, | ||
| @Res() res: Response, | ||
| ): Promise<void> { | ||
| this.logger.log( | ||
| `MCP DELETE received - Project: ${projectApiKey}, SessionId: ${mcpSessionId || 'none'}`, | ||
| `MCP DELETE received - Alias: ${alias}, SessionId: ${mcpSessionId || 'none'}`, | ||
| ); | ||
|
|
||
| // Resolve alias → actual project API key | ||
| const projectApiKey = await this.resolveAlias(alias, undefined, res); | ||
| if (!projectApiKey) return; | ||
|
|
||
| // Validate auth | ||
| const authResult = this.validateAuth(authorization, projectApiKey, undefined, res); | ||
| if (!authResult) return; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add production health checks before wiring more runtime dependencies.
This file still has no health checks for either the app or Redis service, so Compose only guarantees startup order, not readiness. That gets riskier now that MCP requests also depend on the new alias Redis path.
As per coding guidelines, "docker-compose.yml: Docker Compose for production must include both app and Redis services with proper health checks and restart policies."
🤖 Prompt for AI Agents