본문으로 건너뛰기

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()
};
}
}

관련 문서