> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel에서 MCP 서버 만들기

> laravel/mcp 패키지를 사용하여 프로덕션에서 운영 가능한 MCP 서버를 구축합니다. 도구·리소스·프롬프트 구현부터 인증·테스트·배포까지, 실전에 필요한 구현 지식을 설명합니다.

## MCP 서버란(고급 해설)

**Model Context Protocol(MCP)** 은 AI 클라이언트(Claude, Cursor, GitHub Copilot 등)와 애플리케이션이 표준화된 프로토콜로 통신하기 위한 사양입니다. MCP에는 3가지 주요 프리미티브가 있습니다.

| 프리미티브              | 역할               | 주요 용도                    |
| ------------------ | ---------------- | ------------------------ |
| **도구(Tools)**      | AI가 호출할 수 있는 함수  | 데이터 조작, 외부 API 연동, 계산 처리 |
| **리소스(Resources)** | AI가 읽을 수 있는 컨텍스트 | 문서, 설정 정보, 동적 데이터        |
| **프롬프트(Prompts)**  | 재사용 가능한 템플릿      | 정형 쿼리, 워크플로 유도           |

Laravel에서 MCP 서버를 구축하는 이점은 Eloquent, 캐시, 인증, 유효성 검증과 같은 Laravel 생태계를 그대로 활용할 수 있다는 점입니다.

<Info>
  이 고급 가이드에서는 실전 구현에 초점을 맞춥니다. MCP의 기본 개념은 [중급: Laravel MCP](/ko/mcp)를 참조하세요.
</Info>

## 설치와 초기 설정

<Steps>
  <Step title="패키지 설치">
    Composer로 패키지를 설치합니다.

    ```shell theme={null}
    composer require laravel/mcp
    ```
  </Step>

  <Step title="라우트 파일 공개">
    `vendor:publish`로 MCP 서버 등록 위치가 되는 `routes/ai.php`를 생성합니다.

    ```shell theme={null}
    php artisan vendor:publish --tag=ai-routes
    ```
  </Step>

  <Step title="서버 클래스 생성">
    Artisan 명령어로 서버 클래스를 생성합니다.

    ```shell theme={null}
    php artisan make:mcp-server DatabaseServer
    ```

    생성된 `app/Mcp/Servers/DatabaseServer.php`에 도구, 리소스, 프롬프트를 등록합니다.
  </Step>

  <Step title="서버 등록">
    `routes/ai.php`에서 서버를 라우트에 등록합니다.

    <CodeGroup>
      ```php Web 서버 theme={null}
      use App\Mcp\Servers\DatabaseServer;
      use Laravel\Mcp\Facades\Mcp;

      Mcp::web('/mcp/database', DatabaseServer::class);
      ```

      ```php 로컬 서버 theme={null}
      use App\Mcp\Servers\DatabaseServer;
      use Laravel\Mcp\Facades\Mcp;

      Mcp::local('database', DatabaseServer::class);
      ```
    </CodeGroup>

    Web 서버는 HTTP POST를 통해 접근합니다. 로컬 서버는 Artisan 명령어로 동작하며 CLI 기반 AI 클라이언트와의 연동에 사용됩니다.
  </Step>
</Steps>

## 도구 구현

도구는 AI 클라이언트가 호출할 수 있는 함수입니다. Laravel의 서비스 컨테이너, 유효성 검증, Eloquent를 그대로 사용할 수 있습니다.

### 도구 생성

```shell theme={null}
php artisan make:mcp-tool SearchUsersTool
```

생성된 클래스에 `handle` 메서드와 `schema` 메서드를 구현합니다.

### 파라미터 정의(스키마)

`schema` 메서드에서 `Illuminate\Contracts\JsonSchema\JsonSchema` 빌더를 사용해 받아들일 파라미터를 정의합니다.

```php theme={null}
<?php

namespace App\Mcp\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('이름 또는 이메일 주소로 사용자를 검색합니다.')]
class SearchUsersTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'query'   => 'required|string|max:100',
            'limit'   => 'integer|min:1|max:50',
            'role'    => 'nullable|string|in:admin,editor,viewer',
        ], [
            'query.required' => '검색 키워드를 지정해 주세요. 예: "김철수" 또는 "kim@example.com"',
            'limit.max'      => '조회 건수는 최대 50건까지 지정할 수 있습니다.',
            'role.in'        => '역할은 admin, editor, viewer 중 하나를 지정해 주세요.',
        ]);

        $users = \App\Models\User::query()
            ->where(function ($q) use ($validated) {
                $q->where('name', 'like', "%{$validated['query']}%")
                  ->orWhere('email', 'like', "%{$validated['query']}%");
            })
            ->when(isset($validated['role']), fn ($q) => $q->where('role', $validated['role']))
            ->limit($validated['limit'] ?? 10)
            ->get(['id', 'name', 'email', 'role']);

        if ($users->isEmpty()) {
            return Response::text('조건에 일치하는 사용자를 찾을 수 없습니다.');
        }

        $result = $users->map(fn ($u) => "ID:{$u->id} {$u->name} <{$u->email}> [{$u->role}]")
                        ->implode("\n");

        return Response::text($result);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'query' => $schema->string()
                ->description('검색 키워드. 이름 또는 이메일 주소로 검색합니다.')
                ->required(),

            'limit' => $schema->integer()
                ->description('조회 건수 상한(기본값: 10, 최대: 50).')
                ->minimum(1)
                ->maximum(50)
                ->default(10),

            'role' => $schema->string()
                ->description('필터링할 역할. admin, editor, viewer 중 하나.')
                ->enum(['admin', 'editor', 'viewer']),
        ];
    }
}
```

### 도구 어노테이션

MCP 프로토콜의 어노테이션을 사용하면 AI 클라이언트가 도구의 안전성을 판단할 수 있습니다.

```php theme={null}
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[IsReadOnly]      // 환경을 변경하지 않는 읽기 전용 도구
#[IsIdempotent]    // 동일한 인자로 여러 번 호출해도 결과가 변하지 않음
class SearchUsersTool extends Tool
{
    // ...
}
```

| 어노테이션              | 설명                |
| ------------------ | ----------------- |
| `#[IsReadOnly]`    | 데이터를 변경하지 않음      |
| `#[IsDestructive]` | 삭제 등 파괴적인 작업을 수행  |
| `#[IsIdempotent]`  | 동일한 인자에서의 재실행이 안전 |
| `#[IsOpenWorld]`   | 외부 엔티티와 통신        |

### 구조화된 응답

AI 클라이언트가 파싱하기 쉬운 JSON 형식의 응답을 반환하려면 `Response::structured`를 사용합니다.

```php theme={null}
return Response::structured([
    'total' => $users->count(),
    'users' => $users->map(fn ($u) => [
        'id'    => $u->id,
        'name'  => $u->name,
        'email' => $u->email,
    ])->toArray(),
]);
```

### 스트리밍 응답

장시간이 걸리는 처리에서는 Generator를 반환하여 도중 진행 상황을 스트리밍할 수 있습니다.

```php theme={null}
use Generator;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;

public function handle(Request $request): Generator
{
    $items = \App\Models\Product::all();
    $total = $items->count();

    foreach ($items as $index => $item) {
        yield Response::notification('processing/progress', [
            'current' => $index + 1,
            'total'   => $total,
            'name'    => $item->name,
        ]);

        // 무거운 처리...
        yield Response::text("처리 완료: {$item->name}");
    }
}
```

Web 서버에서는 스트리밍 응답이 자동으로 SSE(Server-Sent Events) 스트림으로 전송됩니다.

### 조건부 등록

특정 조건을 충족하는 사용자에게만 도구를 공개할 수 있습니다.

```php theme={null}
public function shouldRegister(Request $request): bool
{
    return $request?->user()?->hasRole('admin') ?? false;
}
```

## 리소스 구현

리소스는 AI 클라이언트가 컨텍스트로 읽어 오는 데이터입니다. 문서, 설정 정보, 동적 데이터를 제공합니다.

### 정적 리소스

```shell theme={null}
php artisan make:mcp-resource AppDocumentationResource
```

```php theme={null}
<?php

namespace App\Mcp\Resources;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;

#[Uri('app://resources/documentation')]
#[MimeType('text/markdown')]
#[Description('애플리케이션의 API 문서와 비즈니스 규칙 개요.')]
class AppDocumentationResource extends Resource
{
    public function handle(Request $request): Response
    {
        $content = \Illuminate\Support\Facades\Storage::get('docs/api-overview.md')
            ?? '문서를 찾을 수 없습니다.';

        return Response::text($content);
    }
}
```

### 동적 리소스(URI 템플릿)

URI 템플릿을 사용하면 URL의 파라미터를 기반으로 동적인 리소스를 제공할 수 있습니다.

```php theme={null}
<?php

namespace App\Mcp\Resources;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Support\UriTemplate;

#[MimeType('application/json')]
#[Description('사용자 ID를 지정하여 사용자 정보를 가져옵니다.')]
class UserProfileResource extends Resource implements HasUriTemplate
{
    public function uriTemplate(): UriTemplate
    {
        return new UriTemplate('app://users/{userId}/profile');
    }

    public function handle(Request $request): Response
    {
        $userId = $request->get('userId');

        $user = \App\Models\User::find($userId);

        if (! $user) {
            return Response::error("사용자 ID {$userId}를 찾을 수 없습니다.");
        }

        return Response::text(json_encode([
            'id'         => $user->id,
            'name'       => $user->name,
            'email'      => $user->email,
            'created_at' => $user->created_at->toIso8601String(),
        ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }
}
```

AI 클라이언트는 `app://users/42/profile`과 같은 URI로 리소스를 요청하며, `{userId}`의 값은 `$request->get('userId')`로 가져올 수 있습니다.

### 리소스 어노테이션

리소스의 우선순위나 대상 오디언스를 명시할 수 있습니다.

```php theme={null}
use Laravel\Mcp\Enums\Role;
use Laravel\Mcp\Server\Annotations\Audience;
use Laravel\Mcp\Server\Annotations\Priority;

#[Audience(Role::Assistant)]   // AI 어시스턴트 대상
#[Priority(0.8)]               // 중요도(0.0~1.0)
class AppDocumentationResource extends Resource
{
    // ...
}
```

## 프롬프트 구현

프롬프트는 AI 클라이언트가 사용할 수 있는 재사용 가능한 템플릿입니다. 정형적인 쿼리나 복잡한 워크플로를 표준화합니다.

### 프롬프트 생성

```shell theme={null}
php artisan make:mcp-prompt DataAnalysisPrompt
```

```php theme={null}
<?php

namespace App\Mcp\Prompts;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;

#[Description('데이터 분석 보고서를 생성하기 위한 프롬프트 템플릿.')]
class DataAnalysisPrompt extends Prompt
{
    public function arguments(): array
    {
        return [
            new Argument(
                name: 'target',
                description: '분석 대상. 예: "지난달 매출", "사용자 등록 수 추이"',
                required: true,
            ),
            new Argument(
                name: 'format',
                description: '출력 형식. "summary"(요약) 또는 "detail"(상세)',
                required: false,
            ),
        ];
    }

    public function handle(Request $request): array
    {
        $validated = $request->validate([
            'target' => 'required|string|max:200',
            'format' => 'nullable|in:summary,detail',
        ]);

        $target = $validated['target'];
        $format = $validated['format'] ?? 'summary';
        $instruction = $format === 'detail'
            ? '수치, 트렌드, 이상치, 권장 조치를 포함한 상세한 보고서를 작성해 주세요.'
            : '주요 지표와 3가지 중요한 통찰을 포함한 간결한 요약을 작성해 주세요.';

        return [
            Response::text(
                "당신은 데이터 분석가입니다. {$instruction}"
            )->asAssistant(),
            Response::text(
                "다음 데이터를 분석해 주세요: {$target}"
            ),
        ];
    }
}
```

<Tip>
  `asAssistant()`를 사용하면 메시지가 AI 어시스턴트의 발언으로 취급됩니다. 시스템 프롬프트와 사용자 메시지를 조합하여 AI의 동작을 세밀하게 제어할 수 있습니다.
</Tip>

## 인증과 인가

### Sanctum을 이용한 토큰 인증

가장 간단한 인증 방식입니다. MCP 클라이언트는 `Authorization: Bearer <token>` 헤더를 부여합니다.

```php theme={null}
// routes/ai.php
Mcp::web('/mcp/database', DatabaseServer::class)
    ->middleware('auth:sanctum');
```

### OAuth 2.1을 이용한 인증

보다 견고한 인증을 위해서는 Laravel Passport를 사용합니다.

```php theme={null}
// routes/ai.php
use Laravel\Mcp\Facades\Mcp;

Mcp::oauthRoutes();

Mcp::web('/mcp/database', DatabaseServer::class)
    ->middleware('auth:api');
```

OAuth 인증을 사용할 경우, MCP에서 제공하는 인가 뷰를 공개하고 `AppServiceProvider`에 설정합니다.

```shell theme={null}
php artisan vendor:publish --tag=mcp-views
```

```php theme={null}
// app/Providers/AppServiceProvider.php
use Laravel\Passport\Passport;

public function boot(): void
{
    Passport::authorizationView(function ($parameters) {
        return view('mcp.authorize', $parameters);
    });
}
```

### 커스텀 미들웨어를 이용한 인증

자체 API 토큰을 사용하는 경우, 커스텀 미들웨어로 `Authorization` 헤더를 검증합니다.

```php theme={null}
// app/Http/Middleware/McpTokenMiddleware.php
public function handle($request, Closure $next)
{
    $token = $request->bearerToken();

    if (! $token || ! \App\Models\ApiToken::where('token', hash('sha256', $token))->exists()) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return $next($request);
}
```

### 도구 내부에서의 인가

도구나 리소스의 `handle` 메서드 내부에서 `$request->user()`를 사용하여 세밀한 인가 검사를 할 수 있습니다.

```php theme={null}
public function handle(Request $request): Response
{
    $user = $request->user();

    if (! $user?->can('manage-users')) {
        return Response::error('이 도구를 사용할 권한이 없습니다. 관리자에게 문의하세요.');
    }

    // 인가된 처리를 계속...
}
```

<Warning>
  `shouldRegister`는 도구를 목록에서 숨기기만 합니다. 도구가 호출될 때의 인가 검사는 반드시 `handle` 메서드 내부에서 수행하세요.
</Warning>

## 실전 예제: 데이터베이스 조작 도구

Eloquent를 사용해 데이터를 검색·생성하는 도구의 전체 구현 예제입니다.

### 서버 클래스

```php theme={null}
<?php

namespace App\Mcp\Servers;

use App\Mcp\Tools\CreatePostTool;
use App\Mcp\Tools\SearchPostsTool;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
use Laravel\Mcp\Server;

#[Name('Blog Management Server')]
#[Version('1.0.0')]
#[Instructions('블로그 게시글을 검색·생성할 수 있는 MCP 서버입니다.')]
class BlogServer extends Server
{
    protected array $tools = [
        SearchPostsTool::class,
        CreatePostTool::class,
    ];
}
```

### 검색 도구(읽기 전용)

```php theme={null}
<?php

namespace App\Mcp\Tools;

use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tool;

#[Description('블로그 게시글을 키워드나 상태로 검색합니다.')]
#[IsReadOnly]
#[IsIdempotent]
class SearchPostsTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'keyword' => 'nullable|string|max:100',
            'status'  => 'nullable|in:draft,published,archived',
            'limit'   => 'integer|min:1|max:20',
        ]);

        $posts = Post::query()
            ->when($validated['keyword'] ?? null, fn ($q, $kw) =>
                $q->where('title', 'like', "%{$kw}%")
                  ->orWhere('body', 'like', "%{$kw}%")
            )
            ->when($validated['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
            ->latest()
            ->limit($validated['limit'] ?? 5)
            ->get(['id', 'title', 'status', 'published_at']);

        if ($posts->isEmpty()) {
            return Response::text('조건에 일치하는 게시글을 찾을 수 없습니다.');
        }

        return Response::structured([
            'total' => $posts->count(),
            'posts' => $posts->map(fn ($p) => [
                'id'           => $p->id,
                'title'        => $p->title,
                'status'       => $p->status,
                'published_at' => $p->published_at?->toIso8601String(),
            ])->toArray(),
        ]);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'keyword' => $schema->string()
                ->description('제목 또는 본문에 포함된 키워드.'),

            'status' => $schema->string()
                ->description('게시글의 상태.')
                ->enum(['draft', 'published', 'archived']),

            'limit' => $schema->integer()
                ->description('조회 건수 상한(기본값: 5, 최대: 20).')
                ->default(5),
        ];
    }
}
```

### 생성 도구(쓰기)

```php theme={null}
<?php

namespace App\Mcp\Tools;

use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('새 블로그 게시글을 초안으로 생성합니다.')]
class CreatePostTool extends Tool
{
    public function handle(Request $request): Response
    {
        $user = $request->user();

        if (! $user?->can('create-posts')) {
            return Response::error('게시글을 생성할 권한이 없습니다.');
        }

        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'body'  => 'required|string',
            'tags'  => 'nullable|array',
            'tags.*' => 'string|max:50',
        ], [
            'title.required' => '게시글 제목을 지정해 주세요.',
            'body.required'  => '게시글 본문을 지정해 주세요.',
        ]);

        $post = Post::create([
            'title'   => $validated['title'],
            'body'    => $validated['body'],
            'status'  => 'draft',
            'user_id' => $user->id,
        ]);

        if (! empty($validated['tags'])) {
            $post->syncTags($validated['tags']);
        }

        return Response::structured([
            'id'         => $post->id,
            'title'      => $post->title,
            'status'     => $post->status,
            'created_at' => $post->created_at->toIso8601String(),
            'message'    => '게시글을 초안으로 생성했습니다.',
        ]);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'title' => $schema->string()
                ->description('게시글 제목(최대 255자).')
                ->required(),

            'body' => $schema->string()
                ->description('게시글 본문. Markdown을 사용할 수 있습니다.')
                ->required(),

            'tags' => $schema->array()
                ->description('게시글에 붙일 태그 배열. 예: ["Laravel", "PHP"]'),
        ];
    }
}
```

## 실전 예제: 파일 시스템 조작 도구

Storage 파사드를 사용하여 파일을 조작하는 도구의 구현 예제입니다.

```php theme={null}
<?php

namespace App\Mcp\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Storage;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tool;

#[Description('스토리지 내 파일 목록을 조회하고, 텍스트 파일의 내용을 읽습니다.')]
#[IsReadOnly]
class ReadFileTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'path'    => 'required|string|max:500',
            'disk'    => 'nullable|string|in:local,public,s3',
        ]);

        $disk = $validated['disk'] ?? 'local';
        $path = $validated['path'];

        // 디렉터리 트래버설 공격 방지
        if (str_contains($path, '..')) {
            return Response::error('유효하지 않은 경로가 지정되었습니다.');
        }

        if (! Storage::disk($disk)->exists($path)) {
            return Response::error("파일을 찾을 수 없습니다: {$path}");
        }

        $mimeType = Storage::disk($disk)->mimeType($path);

        // 텍스트 파일만 내용을 반환
        if (str_starts_with($mimeType, 'text/') || $mimeType === 'application/json') {
            $content = Storage::disk($disk)->get($path);
            return Response::text($content);
        }

        // 이미지 파일은 바이너리로 반환
        if (str_starts_with($mimeType, 'image/')) {
            return Response::fromStorage($path, disk: $disk);
        }

        return Response::error("이 파일 형식은 지원하지 않습니다: {$mimeType}");
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'path' => $schema->string()
                ->description('읽을 파일의 경로. 예: "reports/2025-01.csv"')
                ->required(),

            'disk' => $schema->string()
                ->description('사용할 스토리지 디스크.')
                ->enum(['local', 'public', 's3'])
                ->default('local'),
        ];
    }
}
```

<Warning>
  파일 조작 도구에서는 반드시 경로 새니타이즈를 수행하여, 허용된 디렉터리 이외로의 접근을 차단하세요. `..`를 포함하는 경로는 거부하는 것이 중요합니다.
</Warning>

## 테스트

MCP 서버, 도구, 리소스, 프롬프트는 Laravel 표준 테스트 기능으로 유닛 테스트를 작성할 수 있습니다.

### 도구 테스트

`Server::tool()` 메서드로 도구를 직접 호출하여 테스트합니다.

<CodeGroup>
  ```php Pest theme={null}
  use App\Mcp\Servers\BlogServer;
  use App\Mcp\Tools\SearchPostsTool;
  use App\Models\Post;
  use App\Models\User;

  test('게시글을 검색할 수 있다', function () {
      Post::factory()->create(['title' => 'Laravel의 기초', 'status' => 'published']);
      Post::factory()->create(['title' => 'PHP의 응용', 'status' => 'draft']);

      $response = BlogServer::tool(SearchPostsTool::class, [
          'keyword' => 'Laravel',
          'status'  => 'published',
      ]);

      $response
          ->assertOk()
          ->assertSee('Laravel의 기초');
  });

  test('권한이 없는 사용자는 게시글을 생성할 수 없다', function () {
      $user = User::factory()->create();

      $response = BlogServer::actingAs($user)
          ->tool(\App\Mcp\Tools\CreatePostTool::class, [
              'title' => '테스트 게시글',
              'body'  => '본문',
          ]);

      $response->assertSee('게시글을 생성할 권한이 없습니다');
  });
  ```

  ```php PHPUnit theme={null}
  use App\Mcp\Servers\BlogServer;
  use App\Mcp\Tools\SearchPostsTool;
  use App\Models\Post;
  use App\Models\User;

  public function test_게시글을_검색할_수_있다(): void
  {
      Post::factory()->create(['title' => 'Laravel의 기초', 'status' => 'published']);
      Post::factory()->create(['title' => 'PHP의 응용', 'status' => 'draft']);

      $response = BlogServer::tool(SearchPostsTool::class, [
          'keyword' => 'Laravel',
          'status'  => 'published',
      ]);

      $response
          ->assertOk()
          ->assertSee('Laravel의 기초');
  }

  public function test_권한이_없는_사용자는_게시글을_생성할_수_없다(): void
  {
      $user = User::factory()->create();

      $response = BlogServer::actingAs($user)
          ->tool(\App\Mcp\Tools\CreatePostTool::class, [
              'title' => '테스트 게시글',
              'body'  => '본문',
          ]);

      $response->assertSee('게시글을 생성할 권한이 없습니다');
  }
  ```
</CodeGroup>

### 리소스와 프롬프트 테스트

```php theme={null}
// 리소스 테스트
$response = BlogServer::resource(\App\Mcp\Resources\AppDocumentationResource::class);
$response->assertOk()->assertSee('API');

// 프롬프트 테스트
$response = BlogServer::prompt(\App\Mcp\Prompts\DataAnalysisPrompt::class, [
    'target' => '지난달 매출',
    'format' => 'summary',
]);
$response->assertOk()->assertSee('데이터 분석가');
```

### 주요 어설션 메서드

| 메서드                                      | 설명                       |
| ---------------------------------------- | ------------------------ |
| `assertOk()`                             | 응답에 오류가 없는지 확인           |
| `assertSee($text)`                       | 응답에 지정된 텍스트가 포함되어 있는지 확인 |
| `assertHasErrors()`                      | 응답에 오류가 있는지 확인           |
| `assertHasNoErrors()`                    | 응답에 오류가 없는지 확인           |
| `assertName($name)`                      | 도구 이름을 확인                |
| `assertSentNotification($method, $data)` | 알림이 전송되었는지 확인            |
| `assertNotificationCount($count)`        | 알림 전송 횟수를 확인             |

### MCP Inspector를 이용한 디버깅

인터랙티브한 디버깅에는 MCP Inspector를 사용합니다.

```shell theme={null}
# Web 서버 검사
php artisan mcp:inspector mcp/database

# 로컬 서버 검사
php artisan mcp:inspector database
```

## 배포 시 고려 사항

### HTTP 스트리밍과 SSE

Web 서버에서 스트리밍 응답(Generator)을 사용하는 경우, 서버 설정을 확인하세요.

<CodeGroup>
  ```nginx Nginx theme={null}
  location /mcp {
      proxy_pass http://127.0.0.1:8000;
      proxy_buffering off;          # SSE에서는 버퍼링을 비활성화
      proxy_cache off;
      proxy_read_timeout 3600s;     # 장시간 연결에 대응
      proxy_set_header Connection '';
      chunked_transfer_encoding on;
  }
  ```

  ```apache Apache theme={null}
  # mod_proxy_http를 사용하는 경우
  ProxyPass /mcp http://127.0.0.1:8000/mcp
  ProxyPassReverse /mcp http://127.0.0.1:8000/mcp
  SetEnv proxy-sendchunks 1
  ```
</CodeGroup>

### Laravel Octane과의 조합

트래픽이 많은 MCP 서버에서는 Laravel Octane(FrankenPHP 또는 Swoole) 사용을 검토하세요. 요청당 오버헤드가 크게 줄어듭니다.

<Warning>
  Octane을 사용할 때는 요청 간에 상태가 공유됩니다. 도구 내부에서 정적 프로퍼티나 전역 상태를 사용하지 않도록 주의하세요.
</Warning>

### 레이트 리밋

`throttle` 미들웨어로 MCP 서버에 대한 요청을 제한합니다.

```php theme={null}
// config/cache.php에서 전용 레이트 리미터를 정의
// routes/ai.php
Mcp::web('/mcp/database', DatabaseServer::class)
    ->middleware(['auth:sanctum', 'throttle:60,1']);
```

### 캐시

자주 호출되는 읽기 전용 도구에는 캐시를 활용합니다.

```php theme={null}
public function handle(Request $request): Response
{
    $data = \Illuminate\Support\Facades\Cache::remember(
        "mcp:search:{$request->get('keyword')}",
        now()->addMinutes(5),
        fn () => $this->fetchFromDatabase($request)
    );

    return Response::structured($data);
}
```

### 로그와 모니터링

MCP 도구 호출을 로그에 기록하면 AI 클라이언트의 이용 상황을 파악할 수 있습니다.

```php theme={null}
public function handle(Request $request): Response
{
    \Illuminate\Support\Facades\Log::info('MCP tool called', [
        'tool'   => static::class,
        'user'   => $request->user()?->id,
        'params' => $request->all(),
    ]);

    // ...
}
```

<Tip>
  프로덕션 환경에서는 Laravel Telescope나 Sentry를 사용하여 MCP 서버의 성능과 예외를 모니터링하는 것을 권장합니다.
</Tip>


## Related topics

- [Boost의 커스텀 에이전트 만들기](/ko/advanced/boost-custom-agent.md)
- [AI SDK의 커스텀 프로바이더 만들기](/ko/advanced/ai-sdk-custom-provider.md)
- [⚡Livewire 4 입문 — JavaScript 없이 리액티브한 UI 만들기](/ko/blog/livewire-introduction.md)
- [MCP](/ko/packages/laravel-copilot-sdk/mcp.md)
- [WSL로 PHP와 Composer와 Node.js 설치하기 (Windows)](/ko/blog/windows-setup.md)
