Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
473 changes: 0 additions & 473 deletions .claude/skills/widget-development.md

This file was deleted.

18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0

# ============================================
# Alias Redis (External - Partner alias → API key lookup)
# ============================================

# Connection to external Redis where the dashboard writes alias → projectApiKey mappings.
# Keep separate from session Redis so the two stores are independently configurable.
ALIAS_REDIS_HOST=localhost
ALIAS_REDIS_PORT=6379
ALIAS_REDIS_PASSWORD=
ALIAS_REDIS_DB=0

# Key namespace prefix for alias lookups.
# Format stored by dashboard: {ALIAS_REDIS_KEY_PREFIX}:{alias} → projectApiKey
# Use different prefixes for prod/staging sharing the same Redis instance:
# prod: ALIAS_REDIS_KEY_PREFIX=alias
# staging: ALIAS_REDIS_KEY_PREFIX=alias-staging
ALIAS_REDIS_KEY_PREFIX=alias
7 changes: 7 additions & 0 deletions docker-compose.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ services:
# MCP Session
MCP_SESSION_TTL: ${MCP_SESSION_TTL:-3600}

# Alias Redis (External - partner alias → API key lookup)
ALIAS_REDIS_HOST: ${ALIAS_REDIS_HOST}
ALIAS_REDIS_PORT: ${ALIAS_REDIS_PORT:-6379}
ALIAS_REDIS_PASSWORD: ${ALIAS_REDIS_PASSWORD:-}
ALIAS_REDIS_DB: ${ALIAS_REDIS_DB:-0}
ALIAS_REDIS_KEY_PREFIX: ${ALIAS_REDIS_KEY_PREFIX:-alias-staging}

depends_on:
- redis-staging
networks:
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ services:
# MCP Session
MCP_SESSION_TTL: ${MCP_SESSION_TTL:-3600}

# Alias Redis (External - partner alias → API key lookup)
ALIAS_REDIS_HOST: ${ALIAS_REDIS_HOST}
ALIAS_REDIS_PORT: ${ALIAS_REDIS_PORT:-6379}
ALIAS_REDIS_PASSWORD: ${ALIAS_REDIS_PASSWORD:-}
ALIAS_REDIS_DB: ${ALIAS_REDIS_DB:-0}
ALIAS_REDIS_KEY_PREFIX: ${ALIAS_REDIS_KEY_PREFIX:-alias}
Comment on lines +41 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 41 - 46, The compose file lacks readiness
checks for the app and Redis even though new ALIAS_REDIS_* env vars introduce a
runtime dependency; add healthcheck blocks for the redis service (use redis-cli
PING or equivalent, with sensible interval/timeout/retries/start_period) and for
the app service (HTTP GET /health or startup probe) and set restart:
unless-stopped (or always on prod) for both; update the app's depends_on to wait
for redis to be healthy (use depends_on with condition: service_healthy if your
Compose version supports it, otherwise implement an entrypoint wait-for-redis
that polls ALIAS_REDIS_HOST/ALIAS_REDIS_PORT before starting) so the app only
starts when Redis is ready.


depends_on:
- redis
networks:
Expand Down
5 changes: 5 additions & 0 deletions src/alias/alias.constants.ts
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';
78 changes: 78 additions & 0 deletions src/alias/alias.module.ts
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}`);
}
}
}
44 changes: 44 additions & 0 deletions src/alias/alias.service.ts
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;
}
}
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ToolsModule } from './tools/tools.module';
import { McpModule } from './mcp/mcp.module';
import { RedisModule } from './redis/redis.module';
import { WidgetsModule } from './widgets/widgets.module';
import { AliasModule } from './alias/alias.module';

@Module({
imports: [
Expand Down Expand Up @@ -48,6 +49,9 @@ import { WidgetsModule } from './widgets/widgets.module';
// Redis (global)
RedisModule,

// Alias lookup (external Redis — partner alias → project API key)
AliasModule,

// Proxy to Old API
ProxyModule,

Expand Down
65 changes: 53 additions & 12 deletions src/mcp/mcp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep alias lookup failures inside the repo's JSON-RPC error contract.

resolveAlias() introduces -32004, and a Redis exception from aliasService.resolveAlias() would currently bubble out as a framework 500 instead of a JSON-RPC error. Also use ?? null for id here so valid JSON-RPC ids like 0 are preserved on the error path.

As per coding guidelines, "MCP responses must follow JSON-RPC 2.0 error format: { jsonrpc: '2.0', error: { code, message }, id } for errors" and "MCP error codes must be: -32001 (Unauthorized), -32603 (Internal error)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mcp.controller.ts` around lines 58 - 75, Wrap the call to
aliasService.resolveAlias inside resolveAlias in a try/catch so any Redis or
other exceptions are caught and returned as a JSON-RPC error (code -32603)
instead of bubbling as an HTTP 500; if resolveAlias returns no apiKey, respond
with a JSON-RPC error object (use code -32603 and a clear "Not Found: Unknown
alias '<alias>'" message) and ensure the id uses (body as Record<string,
unknown>)?.id ?? null so values like 0 are preserved. Reference: resolveAlias
and aliasService.resolveAlias; convert all alias lookup failures and thrown
errors to the MCP JSON-RPC error contract with code -32603 and do not rethrow.

}

/**
* Validates Bearer token and returns AuthInfo + userId.
* Sends 401 response and returns null on failure.
Expand Down Expand Up @@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do the cheap Bearer validation before resolving the alias.

POST/GET/DELETE now hit external Redis before any auth check, which lets anonymous callers distinguish registered aliases via the 404/401 split and spends Redis capacity on requests that should be rejected immediately. Split the generic Bearer validation from the project-scoped bits so unauthenticated requests fail before resolveAlias().

As per coding guidelines, "McpController must extract userId from JWT Bearer token via jwt.decode() and validate token before processing requests."

Also applies to: 266-271, 317-322

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/mcp.controller.ts` around lines 164 - 169, The code currently calls
resolveAlias(...) before doing the cheap JWT Bearer check, allowing
unauthenticated callers to hit external Redis; move the generic Bearer
validation (extract and decode the token with jwt.decode() and ensure a valid
userId) into McpController before any call to resolveAlias, splitting
validateAuth into two steps: a token-only check that returns 401 on failure and
a project-scoped authorization that runs after resolveAlias; update the call
sites that call resolveAlias then validateAuth (including the other two similar
places) to perform jwt.decode()/token validation first and only then call
resolveAlias(...) and the project-specific validateAuth(...) so unauthenticated
requests are rejected immediately.

if (!authResult) return;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading