NestJS MCP 서버
npm i @nestjs/mcp @modelcontextprotocol/server zod
npm i -D @modelcontextprotocol/client # 인메모리 테스트 클라이언트
Zod 4.2+와 ArkType은 직접 지원됩니다. Valibot은 toStandardJsonSchema()를 통해, 기존 JSON Schema는 fromJsonSchema()를 통해 사용할 수 있습니다. Zod 3 스키마는 시작 시 거부됩니다.
McpModule.forRoot({
name: 'cat-store', version: '1.0.0',
instructions: '변경 전에 find-order로 주문을 조회하세요.', // 모델이 서버를 사용하는 방법을 알려줌
}),
app.listen(3000) 후에 엔드포인트는 /mcp에서 제공됩니다. npx @modelcontextprotocol/inspector로 검사할 수 있습니다. 시작 시 모듈은 프로바이더(모든 모듈 또는 include에 지정된 모듈)에서 데코레이트된 메서드를 스캔합니다. 중복된 이름, 잘못된 스키마 또는 auth 없이 선언된 scopes가 있으면 빠르게 실패합니다.
프리미티브: 프로바이더의 메서드
| 프리미티브 | 데코레이터 | 사용자 | 호출 시 전달되는 인자 |
|---|---|---|---|
| Tool (액션) | @Tool({ name, description, inputSchema, outputSchema?, annotations?, scopes? }) | 모델 | 검증된 입력, ctx |
| Resource (데이터) | @Resource({ uri, mimeType }) / @ResourceTemplate({ uriTemplate: 'orders://{orderId}' }) | 클라이언트 (주로 사용자가 첨부) | {} 또는 템플릿 변수, ctx (ctx.uri) |
| Prompt (템플릿) | @Prompt({ name, description, argsSchema }) | 사용자 선택 | 검증된 인자, ctx |
@Injectable() // 모듈의 providers에 나열된 일반 프로바이더
export class OrdersTools {
constructor(private readonly orders: OrdersService) {}
@Tool({
name: 'find-order',
description: '주문 ID로 주문을 찾거나, 이메일로 고객의 최신 주문을 찾습니다.',
inputSchema: z.object({
orderId: z.string().optional().describe('주문 ID (예: ord_123)'),
email: z.email().optional().describe('고객의 이메일 주소'),
}),
annotations: { readOnlyHint: true }, // 클라이언트가 사용자에게 묻지 않고 실행 가능
})
async find({ orderId, email }: { orderId?: string; email?: string }) {
const found = orderId ? [await this.orders.findById(orderId)] : await this.orders.latestFor(email!);
if (!found[0]) throw new NotFoundException('해당 주문 없음'); // → isError: true인 도구 결과
return found;
}
@Tool({
name: 'change-address',
description: '아직 배송되지 않은 주문의 배송 주소를 변경합니다.',
inputSchema: z.object({ orderId: z.string(), address: z.string().min(10) }),
outputSchema: z.object({ orderId: z.string(), address: z.string(), status: z.string() }),
scopes: ['orders:write'],
})
async changeAddress({ orderId, address }: { orderId: string; address: string }, ctx: McpContext) {
const o = await this.orders.changeAddress(orderId, address, ctx.authInfo?.clientId);
return { orderId: o.id, address: o.address, status: o.status }; // → structuredContent + JSON 텍스트
}
}
반환 값: 도구의 문자열은 텍스트 블록이 됩니다. 객체는 outputSchema가 있으면 structuredContent가 되고, 없으면 JSON 텍스트가 됩니다. 이미지, 오디오 또는 임베디드 리소스의 경우 CallToolResult를 직접 반환합니다. undefined는 빈 결과를 반환합니다. 리소스의 문자열은 텍스트로, Buffer/Uint8Array는 블롭(blob)으로, 객체는 JSON으로 전송됩니다. 프롬프트의 문자열은 하나의 사용자 메시지가 되고, 배열은 PromptMessage[]를 반환합니다. 도구 이름은 1~128자로 A-Z a-z 0-9 _ - . 문자만 허용되며, 기본값은 메서드 이름입니다.
이름, 설명 및 .describe() 텍스트는 모델을 위해 작성하세요. 이것이 모델이 아는 전부입니다: 도구가 무엇을 하는지, 언제 사용하는지, 무언가를 변경하는지, 각 인자의 예시 값을 제공하세요. 있는 그대로 로드되는 콘텐츠에는 리소스를 사용하고, 인자가 필요한 조회에는 도구를 사용하세요.
호출 컨텍스트
ctx: McpContext에는 다음 속성이 있습니다:
kind,name,uriauthInfo(clientId,scopes,expiresAt,extra)request(플랫폼 요청; stdio에서는undefined)requestInfotransport('http' | 'stdio')era('modern'2026-07-28 /'legacy'2025)signal:fetch및 DB 호출에 전달하고, 루프에서throwIfAborted()호출reportProgress(progress, total?, message?)server(SDK 컨텍스트)
사용자에게 질문(확인, 누락된 값)하려면 inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema }) } })를 반환하세요. 재시도 시 acceptedContent(ctx.server.mcpReq.inputResponses, 'confirm', Schema)로 응답을 읽습니다. HTTP에서는 엔드포인트가 상태 비저장(stateless)이므로 2026-07-28 클라이언트에서만 작동합니다. stdio에서는 두 시대 모두 작동합니다.
프로바이더는 싱글톤으로 유지됩니다. 요청 범위 프로바이더 대신 ctx에서 호출별 데이터를 읽으세요.
인핸서(Enhancer) 및 오류
- 가드, 인터셉터, 파이프 및 필터는 라우트와 동일하게 실행되며,
context.getType() === 'mcp'입니다.McpExecutionContext.create(context)는getInput(),getContext(),getKind(),getName(),getRequest()및getAuthInfo()를 제공합니다. - 전역 인핸서는 MCP 호출과 이를 전달하는 HTTP 요청 모두에서 실행됩니다. HTTP 전용 인핸서는
switchToHttp()를 호출하기 전에context.getType() === 'http'를 확인해야 합니다. - 전역
ValidationPipe({ transform: true })는 DTO 타입의 입력을 검증합니다. 커스텀 파라미터 데코레이터는(input, ctx, @CurrentUser() user)뒤에 옵니다. - 도구에서 발생한 오류는 모델이 복구할 수 있는
isError: true결과가 됩니다.HttpException은 메시지를 유지하고, 다른 오류는Internal server error가 되어 로그에 기록됩니다. - 리소스와 프롬프트는 JSON-RPC 오류로 응답합니다:
NotFoundException→ 리소스를 찾을 수 없음,BadRequestException→ 잘못된 파라미터. - 필터:
host.getType<McpContextType>() === 'mcp'를 확인하세요. 필터가 반환하는 모든 것이 결과가 됩니다. JSON-RPC 오류를 보내려면ProtocolError를 던지세요.
인증 (네트워크 접근 가능 서버)
McpModule.forRootAsync({
imports: [ConfigModule, AuthModule],
inject: [ConfigService, IdentityProviderTokenVerifier],
useFactory: (config: ConfigService, verifier: IdentityProviderTokenVerifier) => ({
name: 'cat-store', version: config.get('VERSION'),
auth: {
verifier, // McpTokenVerifier.verifyAccessToken(token) → AuthInfo | null 구현
resource: config.get('MCP_RESOURCE'), // 정식 공개 URL = 토큰 audience
authorizationServers: [config.get('IDENTITY_PROVIDER_URL')],
scopesSupported: ['orders:read', 'orders:write'],
},
}),
}),
- 서버는 OAuth 2.1 리소스 서버입니다: 토큰을 검증하고 절대 발급하지 않습니다.
/.well-known/oauth-protected-resource/mcp에서 RFC 9728 메타데이터를 제공합니다. 토큰이 없는 요청은 401과WWW-Authenticate: Bearer챌린지를 받으며, 이는 클라이언트의 로그인을 시작합니다.- 검증기(Verifier):
- JWT:
@nestjs/authentication의JwtVerifier사용 (서명, 발급자, audience 및 만료 확인). - 불투명 토큰: RFC 7662 introspection 엔드포인트 호출.
- 반환된
AuthInfo는resource === auth.resource이고 미래의expiresAt을 가져야 합니다. 그렇지 않으면 401로 토큰이 거부됩니다. - 401 오류를 던져 토큰을 거부하고, 다른 오류는 500이 됩니다.
- JWT:
- 프리미티브에
scopes가 없으면 403과 단계 업(step-up) 챌린지가 발생합니다.auth.requiredScopes는 모든 요청에 적용됩니다. @nestjs/authentication≥ 0.1.3 및@nestjs/authorization≥ 0.1.1과 함께 사용하는 경우:- 전역 가드가 각 호출을 인증하고,
@CurrentUser()와@Can(Policy, 'action')이 도구에서 작동합니다. - 엔드포인트에
decorators: [Public()]을 설정하여auth가 챌린지를 처리하도록 합니다. @Can()이 없는 도구는 거부됩니다.
- 전역 가드가 각 호출을 인증하고,
- 내부 전용 서버는 OAuth를 건너뛰고 엔드포인트에
decorators: [UseGuards(ApiKeyGuard)]를 사용할 수 있습니다.
전송
- Streamable HTTP가 기본으로 제공됩니다.
path: false로 비활성화할 수 있습니다. 엔드포인트는 일반 컨트롤러이므로 전역 접두사, 버전 관리(endpointVersion), 미들웨어 및 CORS가 모두 적용됩니다. 상태 비저장(stateless)이므로 고정 세션(sticky session)이 필요 없습니다. 두 프로토콜 시대 모두 제공됩니다(http.legacy: 'reject'는 2026-07-28만 제공).http.responseMode는'auto','json'또는'sse'일 수 있습니다. - stdio: 로컬 프로세스를 실행하는 어시스턴트용:
NestFactory.createApplicationContext(AppModule, { logger: new McpStdioLogger() })로stdio.ts를 생성하고,await app.get(McpStdioServer).listen(); await app.close();를 실행합니다. stdout에 다른 것을 쓰면 프로토콜이 손상됩니다. 자격 증명은process.env에서 가져옵니다. 스코프는 검사되지 않지만 가드는 실행됩니다.
전체 옵션 테이블, 프로덕션 설정, stdio 클라이언트 구성, 테스트 패턴 및 마이그레이션 맵은 references/mcp-reference.md를 참조하세요.
작업 흐름
- 1. 설치; Zod 4.2+ 확인;
name,version,instructions로McpModule.forRoot설정. - 2. 프로바이더에 모델 지향 설명, 모든 인자에
.describe(), 읽기 전용 도구에readOnlyHint, 구조화된 결과에outputSchema를 포함하여 도구, 리소스 및 프롬프트를 추가합니다. - 3. HTTP 전용 전역 인핸서가
getType()을 확인하는지 확인하고, 필요에 따라 MCP 가드 및 필터를 추가합니다. - 4. 네트워크 접근 가능 서버:
auth(검증기, 리소스, 인증 서버, 스코프),allowedHosts/allowedOrigins, CORS 헤더 및 본문 크기 제한을 추가합니다. 로컬 서버:McpStdioLogger를 사용하여stdio.ts를 추가합니다. - 5. 테스트: 메서드를 직접 단위 테스트; 테스트 검증기를 사용하여
connectMcpClient(moduleRef, { token })로 e2e 테스트; 소켓을 통해 HTTP 라우트를 테스트합니다. 빌드, 린트, 테스트 실행, 앱 시작 및 Inspector로 도구 목록을 확인합니다.
참고 파일
references/mcp-reference.md: 모듈 옵션, 데코레이터 옵션, HTTP 강화(Origin, Host, CORS 헤더, 본문 크기), stdio 클라이언트 구성, NestJS Observe 명명, 프로덕션 체크리스트, 테스트(토큰 및 스코프, 프로토콜 시대, McpRegistry 검사, HTTP 및 stdio 테스트), 및@rekog/mcp-nest1.x/2.x에서 마이그레이션. 프로덕션 구성, 테스트 작성 또는 마이그레이션 시 참조하세요.