MCP 인증 구현 가이드
개요
이 문서는 MCP (Model Context Protocol) 서버에서 User Token Delegation Pattern을 구현할 때의 개발 가이드라인을 제공합니다. 실제 구현 예제와 모범 사례를 통해 안전하고 효율적인 인증 시스템을 구축할 수 있도록 안내합니다.
실제 구현 프로젝트
dta-wide-mcp 프로젝트에서 이 가이드라인을 기반으로 구현된 주요 컴포넌트:
-
TokenManagerService (
src/mcp-server/services/token-manager.service.ts)- 사용자별 토큰 캐싱 및 자동 갱신
- CQRS RefreshTokenCommand 연동
- 메모리 기반 토큰 저장소
-
ApiClientService (
src/mcp-server/services/api-client.service.ts)- HTTP 클라이언트 래퍼 with 자동 토큰 주입
- 401 에러 감지 및 자동 토큰 갱신
- 환경별 설정 지원 (로컬/개발/운영)
-
McpToolsService (
src/mcp-server/services/mcp-tools.service.ts)- 하이브리드 인증 지원 (none/service-account/user-token)
- 사용자 개인 데이터 접근 툴 구현
- 시스템 레벨 툴 구현
구현 완료 기능
✅ 기본 토큰 위임 패턴
✅ 자동 토큰 갱신
✅ 환경별 설정 지원
✅ 기본 오류 처리
✅ 구조화된 로깅
향후 개선 계획
🔄 토큰 암호화 강화
🔄 Redis 기반 분산 저장소
🔄 고급 보안 기능
🔄 성능 모니터링 강화
프로젝트 구조
src/
├── mcp-server/
│ ├── interfaces/
│ │ ├── mcp.interface.ts # MCP 툴 인터페이스 정의
│ │ └── token.interface.ts # 토큰 관련 인터페이스
│ ├── services/
│ │ ├── token-manager.service.ts # 토큰 관리 서비스
│ │ ├── api-client.service.ts # API 클라이언트 서비스
│ │ └── mcp-tools.service.ts # MCP 툴 서비스
│ └── mcp-server.module.ts # 모듈 설정
핵심 인터페이스 정의
1. 토큰 관련 인터페이스
// src/mcp-server/interfaces/token.interface.ts
export interface UserTokens {
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType?: string;
}
export interface TokenCache {
[userId: string]: {
accessToken: string;
refreshToken: string;
expiresAt: number;
encryptedAt: number;
};
}
export interface SessionMetadata {
ipAddress?: string;
userAgent?: string;
deviceId?: string;
timestamp: number;
}
2. MCP 툴 인터페이스
// src/mcp-server/interfaces/mcp.interface.ts
export interface McpTool {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, any>;
required?: string[];
};
authType: 'none' | 'service-account' | 'user-token';
}
export interface McpToolContext {
userId?: string;
sessionId?: string;
metadata?: SessionMetadata;
}
export interface McpToolResult {
content: Array<{
type: 'text';
text: string;
}>;
isError?: boolean;
}
토큰 관리 서비스 구현
1. TokenManagerService
// src/mcp-server/services/token-manager.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { RefreshTokenCommand } from '@dta-wide/feature/auth';
import { UserTokens, TokenCache } from '../interfaces/token.interface';
@Injectable()
export class TokenManagerService {
private readonly logger = new Logger(TokenManagerService.name);
private readonly tokenCache: TokenCache = {};
private readonly TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5분
constructor(private readonly commandBus: CommandBus) {}
/**
* 사용자 세션을 설정하고 토큰을 저장합니다.
*/
async setupUserSession(userId: string, tokens: UserTokens): Promise<void> {
try {
// 토큰 유효성 검증
this.validateTokens(tokens);
// 만료 시간 계산
const expiresAt = tokens.expiresAt || this.calculateExpirationTime(tokens.accessToken);
// 토큰 캐시에 저장 (실제 환경에서는 암호화 필요)
this.tokenCache[userId] = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt,
encryptedAt: Date.now()
};
this.logger.log(`User session setup completed for user: ${userId}`);
} catch (error) {
this.logger.error(`Failed to setup user session: ${error.message}`, error.stack);
throw error;
}
}
/**
* 유효한 토큰을 반환합니다. 필요시 자동으로 갱신합니다.
*/
async getValidToken(userId: string): Promise<string> {
const cachedTokens = this.tokenCache[userId];
if (!cachedTokens) {
throw new Error('No active session found for user');
}
// 토큰 만료 확인 (버퍼 시간 포함)
if (Date.now() + this.TOKEN_EXPIRY_BUFFER >= cachedTokens.expiresAt) {
this.logger.log(`Token expiring soon for user ${userId}, refreshing...`);
await this.refreshUserToken(userId);
return this.tokenCache[userId].accessToken;
}
return cachedTokens.accessToken;
}
/**
* 토큰을 갱신합니다.
*/
async refreshUserToken(userId: string): Promise<UserTokens> {
const cachedTokens = this.tokenCache[userId];
if (!cachedTokens) {
throw new Error('No session to refresh');
}
try {
// CQRS 명령을 통한 토큰 갱신
const refreshCommand = new RefreshTokenCommand(cachedTokens.refreshToken);
const newTokens = await this.commandBus.execute<RefreshTokenCommand, UserTokens>(refreshCommand);
// 새 토큰으로 세션 업데이트
await this.setupUserSession(userId, newTokens);
this.logger.log(`Token refreshed successfully for user: ${userId}`);
return newTokens;
} catch (error) {
this.logger.error(`Token refresh failed for user ${userId}: ${error.message}`);
// 갱신 실패시 세션 정리
await this.cleanupUserSession(userId);
throw new Error(`Token refresh failed: ${error.message}`);
}
}
/**
* 사용자 세션을 정리합니다.
*/
async cleanupUserSession(userId: string): Promise<void> {
delete this.tokenCache[userId];
this.logger.log(`User session cleaned up for user: ${userId}`);
}
/**
* 토큰 만료 여부를 확인합니다.
*/
isTokenExpired(token: string): boolean {
try {
// JWT 토큰의 경우 페이로드에서 exp 확인
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
return Date.now() >= payload.exp * 1000;
} catch {
// 파싱 실패시 만료된 것으로 간주
return true;
}
}
private validateTokens(tokens: UserTokens): void {
if (!tokens.accessToken || !tokens.refreshToken) {
throw new Error('Both access token and refresh token are required');
}
if (this.isTokenExpired(tokens.accessToken)) {
throw new Error('Access token is already expired');
}
}
private calculateExpirationTime(accessToken: string): number {
try {
const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
return payload.exp * 1000;
} catch {
// 기본값: 1시간
return Date.now() + 60 * 60 * 1000;
}
}
}
2. ApiClientService
// src/mcp-server/services/api-client.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { TokenManagerService } from './token-manager.service';
@Injectable()
export class ApiClientService {
private readonly logger = new Logger(ApiClientService.name);
private readonly httpClient: AxiosInstance;
private readonly isLocalEnvironment: boolean;
constructor(
private readonly tokenManager: TokenManagerService,
private readonly configService: ConfigService
) {
this.isLocalEnvironment = this.configService.get('NODE_ENV') === 'local';
this.httpClient = axios.create({
baseURL: this.configService.get('DTA_WIDE_API_BASE_URL'),
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'DTA-Wide-MCP/1.0'
}
});
this.setupInterceptors();
}
/**
* GET 요청을 수행합니다.
*/
async get<T>(url: string, userId?: string): Promise<T> {
return this.makeRequest<T>('GET', url, undefined, userId);
}
/**
* POST 요청을 수행합니다.
*/
async post<T>(url: string, data: any, userId?: string): Promise<T> {
return this.makeRequest<T>('POST', url, data, userId);
}
/**
* PUT 요청을 수행합니다.
*/
async put<T>(url: string, data: any, userId?: string): Promise<T> {
return this.makeRequest<T>('PUT', url, data, userId);
}
/**
* DELETE 요청을 수행합니다.
*/
async delete<T>(url: string, userId?: string): Promise<T> {
return this.makeRequest<T>('DELETE', url, undefined, userId);
}
private async makeRequest<T>(
method: string,
url: string,
data?: any,
userId?: string
): Promise<T> {
const config: AxiosRequestConfig = {
method,
url,
data
};
// 사용자 토큰이 필요한 경우
if (userId) {
if (this.isLocalEnvironment) {
this.logger.warn(`Local environment: Skipping token authentication for ${url}`);
} else {
const token = await this.tokenManager.getValidToken(userId);
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`
};
}
}
try {
const response = await this.httpClient.request<T>(config);
return response.data;
} catch (error) {
// 401 에러시 토큰 갱신 후 재시도
if (error.response?.status === 401 && userId && !this.isLocalEnvironment) {
this.logger.log(`401 error detected, attempting token refresh for user: ${userId}`);
try {
await this.tokenManager.refreshUserToken(userId);
const newToken = await this.tokenManager.getValidToken(userId);
config.headers = {
...config.headers,
Authorization: `Bearer ${newToken}`
};
const retryResponse = await this.httpClient.request<T>(config);
this.logger.log(`Request succeeded after token refresh for user: ${userId}`);
return retryResponse.data;
} catch (refreshError) {
this.logger.error(`Token refresh failed for user ${userId}: ${refreshError.message}`);
throw new Error(`Authentication failed: ${refreshError.message}`);
}
}
this.handleApiError(error, url);
}
}
private setupInterceptors(): void {
// 요청 인터셉터
this.httpClient.interceptors.request.use(
(config) => {
this.logger.debug(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
this.logger.error('Request interceptor error:', error);
return Promise.reject(error);
}
);
// 응답 인터셉터
this.httpClient.interceptors.response.use(
(response) => {
this.logger.debug(`API Response: ${response.status} ${response.config.url}`);
return response;
},
(error) => {
this.logger.error(`API Error: ${error.response?.status} ${error.config?.url}`);
return Promise.reject(error);
}
);
}
private handleApiError(error: any, url: string): never {
const status = error.response?.status;
const message = error.response?.data?.message || error.message;
this.logger.error(`API call failed: ${url} - Status: ${status}, Message: ${message}`);
if (status === 400) {
throw new Error(`Bad request: ${message}`);
} else if (status === 403) {
throw new Error(`Forbidden: ${message}`);
} else if (status === 404) {
throw new Error(`Not found: ${message}`);
} else if (status >= 500) {
throw new Error(`Server error: ${message}`);
} else {
throw new Error(`API call failed: ${message}`);
}
}
}
MCP 툴 서비스 구현
1. 하이브리드 MCP 툴 서비스
// src/mcp-server/services/mcp-tools.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ApiClientService } from './api-client.service';
import { McpTool, McpToolContext, McpToolResult } from '../interfaces/mcp.interface';
@Injectable()
export class McpToolsService {
private readonly logger = new Logger(McpToolsService.name);
constructor(private readonly apiClient: ApiClientService) {}
/**
* 사용 가능한 모든 MCP 툴 목록을 반환합니다.
*/
getAvailableTools(): McpTool[] {
return [
// 시스템 레벨 툴 (인증 불필요)
{
name: 'get-current-time',
description: '현재 시간을 조회합니다',
inputSchema: {
type: 'object',
properties: {
format: { type: 'string', enum: ['ISO', 'Korean', 'US'] },
timezone: { type: 'string' }
}
},
authType: 'none'
},
{
name: 'query-recent-errors',
description: '최근 시스템 오류를 조회합니다',
inputSchema: {
type: 'object',
properties: {
hours: { type: 'number', minimum: 1, maximum: 168 },
limit: { type: 'number', minimum: 1, maximum: 1000 }
}
},
authType: 'service-account'
},
// 사용자 개인 데이터 툴 (사용자 토큰 필요)
{
name: 'get-user-profile',
description: '사용자 프로필 정보를 조회합니다',
inputSchema: {
type: 'object',
properties: {},
required: []
},
authType: 'user-token'
},
{
name: 'update-user-settings',
description: '사용자 설정을 업데이트합니다',
inputSchema: {
type: 'object',
properties: {
settings: { type: 'object' }
},
required: ['settings']
},
authType: 'user-token'
},
{
name: 'get-user-activity-log',
description: '사용자 활동 로그를 조회합니다',
inputSchema: {
type: 'object',
properties: {
startDate: { type: 'string', format: 'date' },
endDate: { type: 'string', format: 'date' },
limit: { type: 'number', minimum: 1, maximum: 100 }
}
},
authType: 'user-token'
}
];
}
/**
* MCP 툴을 실행합니다.
*/
async executeTool(
toolName: string,
args: Record<string, any>,
context: McpToolContext
): Promise<McpToolResult> {
try {
const tool = this.getAvailableTools().find(t => t.name === toolName);
if (!tool) {
throw new Error(`Unknown tool: ${toolName}`);
}
// 인증 타입에 따른 분기 처리
switch (tool.authType) {
case 'none':
return await this.executeSystemTool(toolName, args);
case 'service-account':
return await this.executeServiceAccountTool(toolName, args);
case 'user-token':
if (!context.userId) {
throw new Error('User authentication required for this tool');
}
return await this.executeUserTokenTool(toolName, args, context.userId);
default:
throw new Error(`Unsupported auth type: ${tool.authType}`);
}
} catch (error) {
this.logger.error(`Tool execution failed: ${toolName}`, error.stack);
return {
content: [{
type: 'text',
text: `Error executing tool: ${error.message}`
}],
isError: true
};
}
}
private async executeSystemTool(toolName: string, args: Record<string, any>): Promise<McpToolResult> {
switch (toolName) {
case 'get-current-time':
const now = new Date();
const format = args.format || 'ISO';
const timezone = args.timezone || 'UTC';
let timeString: string;
switch (format) {
case 'Korean':
timeString = now.toLocaleString('ko-KR', { timeZone: timezone });
break;
case 'US':
timeString = now.toLocaleString('en-US', { timeZone: timezone });
break;
default:
timeString = now.toISOString();
}
return {
content: [{
type: 'text',
text: `Current time (${format}, ${timezone}): ${timeString}`
}]
};
default:
throw new Error(`System tool not implemented: ${toolName}`);
}
}
private async executeServiceAccountTool(toolName: string, args: Record<string, any>): Promise<McpToolResult> {
switch (toolName) {
case 'query-recent-errors':
// 서비스 계정으로 시스템 오류 조회
const errors = await this.apiClient.get<any[]>('/system/errors', undefined);
return {
content: [{
type: 'text',
text: `Found ${errors.length} recent errors:\n${JSON.stringify(errors, null, 2)}`
}]
};
default:
throw new Error(`Service account tool not implemented: ${toolName}`);
}
}
private async executeUserTokenTool(
toolName: string,
args: Record<string, any>,
userId: string
): Promise<McpToolResult> {
switch (toolName) {
case 'get-user-profile':
const profile = await this.apiClient.get<any>('/user/profile', userId);
return {
content: [{
type: 'text',
text: `User Profile:\n${JSON.stringify(profile, null, 2)}`
}]
};
case 'update-user-settings':
const updatedSettings = await this.apiClient.put<any>(
'/user/settings',
args.settings,
userId
);
return {
content: [{
type: 'text',
text: `Settings updated successfully:\n${JSON.stringify(updatedSettings, null, 2)}`
}]
};
case 'get-user-activity-log':
const queryParams = new URLSearchParams();
if (args.startDate) queryParams.append('startDate', args.startDate);
if (args.endDate) queryParams.append('endDate', args.endDate);
if (args.limit) queryParams.append('limit', args.limit.toString());
const activityLog = await this.apiClient.get<any[]>(
`/user/activity?${queryParams.toString()}`,
userId
);
return {
content: [{
type: 'text',
text: `Activity Log (${activityLog.length} entries):\n${JSON.stringify(activityLog, null, 2)}`
}]
};
default:
throw new Error(`User token tool not implemented: ${toolName}`);
}
}
}
모듈 설정
1. MCP 서버 모듈
// src/mcp-server/mcp-server.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { ConfigModule } from '@nestjs/config';
import { TokenManagerService } from './services/token-manager.service';
import { ApiClientService } from './services/api-client.service';
import { McpToolsService } from './services/mcp-tools.service';
@Module({
imports: [
CqrsModule,
ConfigModule
],
providers: [
TokenManagerService,
ApiClientService,
McpToolsService
],
exports: [
TokenManagerService,
ApiClientService,
McpToolsService
]
})
export class McpServerModule {}
개발 모범 사례
1. 오류 처리
// 구체적인 오류 타입 정의
export class TokenExpiredError extends Error {
constructor(userId: string) {
super(`Token expired for user: ${userId}`);
this.name = 'TokenExpiredError';
}
}
export class AuthenticationRequiredError extends Error {
constructor(toolName: string) {
super(`Authentication required for tool: ${toolName}`);
this.name = 'AuthenticationRequiredError';
}
}
// 오류 처리 예제
try {
const result = await this.executeTool(toolName, args, context);
return result;
} catch (error) {
if (error instanceof TokenExpiredError) {
// 토큰 만료 처리
await this.handleTokenExpiry(context.userId);
} else if (error instanceof AuthenticationRequiredError) {
// 인증 필요 처리
return this.createAuthRequiredResponse(error.message);
}
throw error;
}
2. 로깅 전략
// 구조화된 로깅
this.logger.log('Tool execution started', {
toolName,
userId: context.userId,
authType: tool.authType,
timestamp: new Date().toISOString()
});
// 민감한 정보 마스킹
this.logger.debug('API request details', {
url: config.url,
method: config.method,
headers: this.maskSensitiveHeaders(config.headers),
userId: context.userId
});
private maskSensitiveHeaders(headers: any): any {
const masked = { ...headers };
if (masked.Authorization) {
masked.Authorization = 'Bearer [MASKED]';
}
return masked;
}
3. 테스트 작성
// src/mcp-server/services/__tests__/token-manager.service.spec.ts
describe('TokenManagerService', () => {
let service: TokenManagerService;
let commandBus: jest.Mocked<CommandBus>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
TokenManagerService,
{
provide: CommandBus,
useValue: {
execute: jest.fn()
}
}
]
}).compile();
service = module.get<TokenManagerService>(TokenManagerService);
commandBus = module.get(CommandBus);
});
describe('setupUserSession', () => {
it('should store user tokens successfully', async () => {
const userId = 'test-user';
const tokens = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3600000
};
await service.setupUserSession(userId, tokens);
const validToken = await service.getValidToken(userId);
expect(validToken).toBe(tokens.accessToken);
});
it('should throw error for invalid tokens', async () => {
const userId = 'test-user';
const invalidTokens = {
accessToken: '',
refreshToken: 'refresh-token'
};
await expect(service.setupUserSession(userId, invalidTokens))
.rejects.toThrow('Both access token and refresh token are required');
});
});
describe('refreshUserToken', () => {
it('should refresh token successfully', async () => {
const userId = 'test-user';
const oldTokens = {
accessToken: 'old-access-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 1000
};
const newTokens = {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: Date.now() + 3600000
};
await service.setupUserSession(userId, oldTokens);
commandBus.execute.mockResolvedValue(newTokens);
const result = await service.refreshUserToken(userId);
expect(result).toEqual(newTokens);
expect(commandBus.execute).toHaveBeenCalledWith(
expect.any(RefreshTokenCommand)
);
});
});
});
4. 환경 설정
// .env.example
NODE_ENV=development
DTA_WIDE_API_BASE_URL=https://api.dta-wide.com
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
SESSION_SECRET=your-session-secret-here
LOG_LEVEL=debug
# 로컬 개발용
LOCAL_SKIP_AUTH=true
// src/config/mcp.config.ts
export const mcpConfig = () => ({
api: {
baseUrl: process.env.DTA_WIDE_API_BASE_URL,
timeout: parseInt(process.env.API_TIMEOUT) || 30000
},
auth: {
tokenExpiryBuffer: parseInt(process.env.TOKEN_EXPIRY_BUFFER) || 300000,
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY,
skipAuthInLocal: process.env.LOCAL_SKIP_AUTH === 'true'
},
logging: {
level: process.env.LOG_LEVEL || 'info',
maskSensitiveData: process.env.NODE_ENV === 'production'
}
});
배포 및 운영
1. 헬스 체크
// src/mcp-server/health/mcp-health.controller.ts
@Controller('health')
export class McpHealthController {
constructor(
private readonly tokenManager: TokenManagerService,
private readonly apiClient: ApiClientService
) {}
@Get()
async checkHealth(): Promise<any> {
const checks = await Promise.allSettled([
this.checkTokenManager(),
this.checkApiConnectivity(),
this.checkMemoryUsage()
]);
return {
status: checks.every(check => check.status === 'fulfilled') ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
checks: {
tokenManager: checks[0].status,
apiConnectivity: checks[1].status,
memoryUsage: checks[2].status
}
};
}
private async checkTokenManager(): Promise<void> {
// 토큰 매니저 상태 확인
const testUserId = 'health-check-user';
// 간단한 상태 확인 로직
}
private async checkApiConnectivity(): Promise<void> {
// API 연결성 확인
await this.apiClient.get('/health');
}
private async checkMemoryUsage(): Promise<void> {
const usage = process.memoryUsage();
const maxHeapSize = 512 * 1024 * 1024; // 512MB
if (usage.heapUsed > maxHeapSize) {
throw new Error('Memory usage too high');
}
}
}
2. 메트릭 수집
// src/mcp-server/metrics/mcp-metrics.service.ts
@Injectable()
export class McpMetricsService {
private readonly metrics = {
toolExecutions: new Map<string, number>(),
tokenRefreshes: 0,
authFailures: 0,
apiCalls: 0
};
recordToolExecution(toolName: string): void {
const current = this.metrics.toolExecutions.get(toolName) || 0;
this.metrics.toolExecutions.set(toolName, current + 1);
}
recordTokenRefresh(): void {
this.metrics.tokenRefreshes++;
}
recordAuthFailure(): void {
this.metrics.authFailures++;
}
recordApiCall(): void {
this.metrics.apiCalls++;
}
getMetrics(): any {
return {
toolExecutions: Object.fromEntries(this.metrics.toolExecutions),
tokenRefreshes: this.metrics.tokenRefreshes,
authFailures: this.metrics.authFailures,
apiCalls: this.metrics.apiCalls,
timestamp: new Date().toISOString()
};
}
}
관련 문서
- 인증 패턴 - 전체 인증 패턴 개요
- 토큰 관리 아키텍처 - 토큰 관리 아키텍처
- 토큰 위임 보안 - 보안 고려사항
- 통합 패턴 - 서비스 간 통합 방법