Laravel에서 권한이 제한된 MCP 서버 구축하기 (백도어를 만들지 않고)
요약
Laravel 환경에서 MCP(Model Context Protocol) 서버를 구축할 때 보안과 권한 관리를 유지하는 방법을 다룹니다. MCP를 새로운 프론트엔드로 간주하여 기존의 권한 부여 체계와 동일한 액션 클래스를 사용함으로써 보안 백도어 생성을 방지하는 패턴을 제시합니다.
핵심 포인트
- MCP 도구는 기존 웹 UI 및 API와 동일한 권한 체계를 공유해야 함
- 추상 베이스 클래스를 활용하여 도구별 권한 확인 로직을 중앙화함
- 권한이 없는 요청에는 부분적 데이터 대신 명확한 에러를 반환해야 함
- LLM의 인자 전달 오류를 방지하기 위해 자동 증가 PK 대신 UUID 사용 권장
저는 오늘 Kong API 게이트웨이를 관리하는 Laravel 앱에 MCP 서버를 연결하는 작업을 진행했습니다. 흥미로운 점은 "AI가 앱과 대화하게 만드는 것"이 아니었습니다. 이제는 이를 위한 퍼스트 파티 패키지(first-party package)가 있어 그 부분은 쉬운 작업이 되었습니다. 진짜 흥미로운 부분은 MCP 레이어가 동일한 규칙을 따르는 "또 다른 UI"일 뿐이어야 하며, 권한 부여(authorization)를 건너뛰는 조용한 백도어가 되지 않도록 보장하는 것이었습니다.
제가 이 문제를 어떻게 생각했는지, 그리고 시스템의 정직함을 유지하기 위해 사용한 패턴들을 소개합니다.
MCP는 새로운 권한 세트가 아니라, 세 번째 프론트엔드입니다
이 앱에는 이미 두 가지 진입로가 있습니다: 웹 UI와 HTTP API입니다. 두 방식 모두 동일한 권한 부여, 동일한 액션 클래스(action classes), 동일한 승인 워크플로우를 거칩니다. MCP 서버를 덧붙일 때, 속도가 빠르다는 이유로 도구들이 "그냥 데이터베이스를 쿼리하게" 만들고 싶은 유혹에 빠지기 쉽습니다. 하지만 그것이 바로 로그인한 사용자가 결코 할 수 없는 일을 수행할 수 있는 AI 에이전트를 만드는 지름길입니다.
그래서 제가 스스로 세운 규칙은 다음과 같습니다: 모든 MCP 도구는 인간이 이미 가지고 있는 권한에 매핑되어야 하며, 모든 쓰기(write) 작업은 웹 UI가 호출하는 것과 동일한 액션 클래스를 거쳐야 합니다. MCP에는 어떠한 특별한 특권도 주어지지 않습니다.
도구 베이스 클래스: 먼저 게이트를 확인하고, 그다음에 작업을 수행하라
laravel/mcp를 사용하면, 도구는 Laravel\·Mcp\\\u00b7Server\\\u00b7Tool을 확장하는 클래스입니다. 각 도구가 인증 확인을 매번 새로 구현하게 하는 대신, 저는 이를 추상 베이스 클래스(abstract base)로 밀어 넣었습니다. 각 구체적인 도구는 필요한 권한만을 선언하며, 베이스 클래스가 호출자가 허용되었는지 여부를 결정합니다.
abstract class GatewayTool extends Tool
{
/** 이 도구가 요구하는 권한, 예: "gateway.manage.services". */
...
여기서 $request->user()는 토큰 보유자입니다. 저는 Sanctum을 사용하여 스코프가 지정된 베어러 토큰(scoped bearer tokens)을 발급하므로, MCP 세션은 실제 권한을 가진 실제 사용자로 인증됩니다. Superadmin은 여전히 일반적인 Gate::before를 통해 우회하므로, 별도의 예외 처리를 할 필요가 없습니다.
보기보다 훨씬 중요한 작은 한 가지는 다음과 같습니다: 권한이 없는 도구는 부분적인 데이터가 아니라 에러를 반환해야 합니다. "당신이 볼 수 있는 부분은 여기 있습니다"와 같은 불완전한 답변은 안 됩니다. 호출할 수 없다면, 깔끔하게 거절되어야 하며 아무것도 유출되지 않아야 합니다.
class ListServicesTool extends GatewayTool
{
protected function permission(): string
...
식별자(identifiers)에 주의하세요. 도구(tools)는 uuid 또는 사람이 읽을 수 있는 code를 입력받고 반환하며, 자동 증가(auto-increment)하는 기본 키(primary key)를 절대 사용하지 않습니다. 내부 ID는 내부적으로만 유지됩니다. 이는 제가 어디에서나 사용하는 동일한 관례이며, LLM(대규모 언어 모델)이 인자를 전달하는 경우에는 이 점이 두 배로 중요합니다.
드라이버 기반 계약을 통한 계층화된 기능 (Tiered capabilities)
모든 배포 환경이 동일한 데이터를 가지고 있지는 않습니다. 집계된 지표(aggregated metrics)는 소규모 설치 환경에서는 게이트웨이 스냅샷(gateway snapshots)에서 가져올 수 있고, 더 큰 환경에서는 PostgreSQL 요청 로그에서, 혹은 헤비 티어(heavy tier)에서는 Elasticsearch에서 가져올 수 있습니다. 저는 분석 도구(analytics tools)가 데이터의 출처가 어디인지 신경 쓰지 않기를 원했습니다.
따라서 분석 도구는 계약(contract)에 의존하며, _컨테이너(container)_는 설정된 티어(tier)에 따라 적절한 구현체(implementation)를 바인딩합니다:
interface UsageMetricsProvider
{
public function usageSummary(Carbon $from, Carbon $to): array;
...
기본 분석 도구는 상세 프로바이더(detailed provider)가 실제로 사용 가능할 때만 이를 해결(resolve)하며, 사용 불가능할 경우 우아하게 성능을 저하시킵니다(degrades gracefully):
abstract class AnalyticsTool extends GatewayTool
{
public function __construct(protected UsageMetricsProvider $metrics) {}
...
상세 도구는 detailedMetrics()를 확인하고, 만약 null이라면 혼란스러운 에러를 던지거나 빈 배열을 반환하는 대신, 호출자에게 _이유_를 알려줍니다. 예를 들어 "이 보고 티어(reporting tier)에서는 사용할 수 없습니다. 로그 기반 티어로 전환하세요"와 같이 말입니다. 마커 인터페이스(marker-interface) 기법(기능이 있는 드라이버만 구현하는 하위 인터페이스)은 코드 곳곳에 if ($tier >= 2)와 같은 체크를 흩뿌리지 않고도 기능을 감지할 수 있는 깔끔한 방법입니다.
쓰기 도구는 액션 클래스를 재사용합니다 — 지름길은 없습니다
이 부분이 제가 가장 중요하게 생각하는 대목입니다. 쓰기 도구(서비스 승인, 구독 만료, 플러그인 설정 등)는 모델(models)을 직접 건드리지 않습니다. 대신 웹 컨트롤러(web controllers)가 호출하는 것과 정확히 동일한 호출 가능한(invokable) 액션 클래스(action classes)를 호출합니다. 이는 다음과 같은 의미를 갖습니다:
- 새로운 서비스는 여전히
draft상태로 시작하며, 게이트웨이(gateway)에 동기화되기 전에 제출되어야 하고, _소유자가 아닌 다른 누군가_에 의해 승인되어야 합니다. - 승인된 서비스의 플러그인을 변경하면 여전히
pending_approval상태로 되돌아갑니다. - 구독(subscription)이 만료되면 여전히 게이트웨이 접근 권한이 취소되며, 갱신하면 다시 복구됩니다.
MCP 도구는 얇은 어댑터(thin adapter)입니다: 입력을 파싱하고, UUID를 통해 리소스를 확인하며, 액션(action)을 호출하고, 응답을 포맷팅합니다. 모든 비즈니스 규칙(business rules)은 한 곳에 모여 있으며, AI는 이를 우회하여 경로를 찾을 수 없습니다. 제가 승인 흐름(approval flow)을 변경하게 되면, MCP 서버는 이를 자동으로 상속받습니다.
또한 저는 의도적으로 몇 가지 사항을 제외했습니다. 파괴적인 삭제(destructive deletion)는 MCP를 통해 전혀 노출되지 않습니다. 이는 웹 전용의, 사람이 주도하는 요청/승인 흐름으로 유지됩니다. 모든 것이 AI가 접근 가능한 도구일 필요는 없으며, "할 수 있는가(can I)"가 "해야 하는가(should I)"를 결정해서는 안 됩니다.
두 개의 서버, 두 개의 대상
결국 저는 동일한 도구 컨벤션(tool conventions)을 공유하는 두 개의 MCP 서버로 분리했습니다: ops 서버(서비스, 라우트, 플러그인 관리, 감사 실행, 전체 분석 데이터 읽기)와 소비자 대상 카탈로그(consumer-facing catalogue) 서버(사용 가능한 API 탐색, 구독 요청, 자신의 사용량 확인)입니다. 동일한 Sanctum 토큰을 사용하지만, 서로 다른 권한 세트(permission sets)가 토큰이 어떤 엔드포인트(endpoint)에 도달할 수 있는지를 결정합니다. 카탈로그 서버는 관리 도구를 볼 수 없습니다. 이는 단순히 UI 토글의 문제가 아니라, 서로 다른 접근 권한에 결합된 별개의 도구 목록입니다.
게이트(gate) 테스트하기
Pest 테스트를 할 가치가 있는 것은 해피 패스(happy path)가 아니라, 거부(refusal)하는 상황입니다. 가장 중요한 어설션(assertion)은 "권한이 없는 사용자는 에러를 받고 데이터가 0개여야 한다"는 것입니다:
it('refuses a tool when the token user lacks the permission', function () {
$user = User::factory()->create(); // 게이트웨이 권한 없음
...
거부 경로(deny path)를 먼저 테스트하세요. 리팩터링 시 가장 조용히 깨지기 쉬운 부분이며, 만약 깨진다면 피해 범위(blast radius)가 가장 큰 부분입니다.
요약(Takeaway)
MCP 서버는 강력한 새로운 공격 표면(surface)이며, 바로 그렇기 때문에 가장 권한이 낮아야(least) 합니다. 즉, 해당 토큰을 소유한 인간보다 더 많은 권한을 가져서는 안 됩니다. 이를 쉽게 유지하기 위한 세 가지 습관이 있습니다. 첫째, 기존 권한을 검증하는 베이스 클래스(base class)를 통해 모든 도구(tool)를 게이트웨이로 통제하십시오. 둘째, 드라이버 기반의 계약(driver-based contract)에 의존하여, 기능이 거짓을 말하는 대신 정직하게 저하(degrade)되도록 하십시오. 셋째, 쓰기 도구(write tools)가 앱의 나머지 부분과 동일한 액션 클래스(action classes)를 호출하도록 만들어 워크플로우를 우회할 수 없게 하십시오. 이렇게 하면 AI는 교묘하게 행동하지 않으면서도 유용하게 활용될 수 있습니다.
다음 단계: 모델이 처음부터 올바른 도구를 선택할 수 있도록 도구 지침(tool instructions)을 강화하는 방법입니다. 잘 작성된 #[Instructions] 블록이 승패의 절반을 결정한다는 사실이 밝혀졌지만, 이는 다음 포스팅에서 다루겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기