refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Laravel\Mcp\Server\Registrar;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
#[AsCommand(
name: 'mcp:inspector',
description: 'Open the MCP Inspector tool to debug and test MCP Servers'
)]
class InspectorCommand extends Command
{
public function handle(Registrar $registrar): int
{
$handle = $this->argument('handle');
if (! is_string($handle)) {
$this->components->error('Please pass a valid MCP server handle');
return static::FAILURE;
}
$this->components->info("Starting the MCP Inspector for server [{$handle}]");
$localServer = $registrar->getLocalServer($handle);
$route = $registrar->getWebServer($handle);
$servers = $registrar->servers();
if ($servers === []) {
$this->components->error('No MCP servers found. Please run `php artisan make:mcp-server [name]`');
return static::FAILURE;
}
// Only one server, we should just run it for them
if (count($servers) === 1) {
$server = array_shift($servers);
[$localServer, $route] = match (true) {
is_callable($server) => [$server, null],
$server::class === Route::class => [null, $server],
default => [null, null],
};
}
if (is_null($localServer) && is_null($route)) {
$availableServers = Arr::map(array_keys($servers), fn ($server): string => "[{$server}]");
$this->components->error('MCP Server with name ['.$handle.'] not found. Available servers: '.Arr::join($availableServers, ', '));
return static::FAILURE;
}
$env = [];
if (is_string($host = $this->option('host'))) {
$env['HOST'] = $host;
}
if (is_string($port = $this->option('port'))) {
$env['CLIENT_PORT'] = $port;
}
if ($localServer !== null) {
$artisanPath = base_path('artisan');
$command = [
'npx',
'@modelcontextprotocol/inspector',
'--transport',
'stdio',
$this->phpBinary(),
$artisanPath,
"mcp:start {$handle}",
];
$guidance = [
'Transport Type' => 'STDIO',
'Command' => $this->phpBinary(),
'Arguments' => implode(' ', [
str_replace('\\', '/', $artisanPath),
'mcp:start',
$handle,
]),
];
} else {
$serverUrl = url($route->uri());
if (parse_url($serverUrl, PHP_URL_SCHEME) === 'https') {
$env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
}
$command = [
'npx',
'@modelcontextprotocol/inspector',
'--transport',
'http',
'--server-url',
$serverUrl,
];
$guidance = [
'Transport Type' => 'Streamable HTTP',
'URL' => $serverUrl,
'Secure' => 'Your project must be accessible on HTTP for this to work due to how node manages SSL trust',
];
}
$process = new Process($command, null, $env);
$process->setTimeout(null);
try {
foreach ($guidance as $guidanceKey => $guidanceValue) {
$this->info(sprintf('%s => %s', $guidanceKey, $guidanceValue));
}
$this->newLine();
$process->mustRun(function (int|string $type, string $buffer): void {
echo $buffer;
});
} catch (Exception $exception) {
$this->components->error('Failed to start MCP Inspector: '.$exception->getMessage());
return static::FAILURE;
}
return static::SUCCESS;
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getArguments(): array
{
return [
['handle', InputArgument::REQUIRED, 'The handle or route of the MCP server to inspect.'],
];
}
/**
* @return array<int, array<int, string|int|null>>
*/
protected function getOptions(): array
{
return [
['host', null, InputOption::VALUE_OPTIONAL, 'The host the inspector should bind to'],
['port', null, InputOption::VALUE_OPTIONAL, 'The port the inspector should bind to'],
];
}
protected function phpBinary(): string
{
return (new PhpExecutableFinder)->find(false) ?: 'php';
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Illuminate\Console\GeneratorCommand;
use Illuminate\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand(
name: 'make:mcp-app-resource',
description: 'Create a new MCP app resource class and linked view'
)]
class MakeAppResourceCommand extends GeneratorCommand
{
/**
* @var string
*/
protected $type = 'AppResource';
public function handle(): ?bool
{
$result = parent::handle();
if ($result === false) {
return false;
}
$this->createBladeView();
return $result;
}
protected function buildClass($name): string
{
$viewName = collect(explode('/', $this->getKebabName()))
->implode('.');
return str_replace(
'{{ view }}',
'mcp.'.$viewName,
parent::buildClass($name),
);
}
protected function getStub(): string
{
return $this->resolveStub('mcp-app-resource.stub');
}
protected function getDefaultNamespace($rootNamespace): string
{
return "{$rootNamespace}\\Mcp\\Resources";
}
protected function createBladeView(): void
{
$viewPath = $this->getViewPath();
if ($this->files->exists($viewPath) && ! $this->option('force')) {
$this->components->warn("View [{$viewPath}] already exists.");
return;
}
$this->files->ensureDirectoryExists(dirname($viewPath));
$this->files->put($viewPath, $this->files->get($this->getViewStub()));
$this->components->info("View [{$viewPath}] created successfully.");
}
protected function getViewStub(): string
{
return $this->resolveStub('mcp-app-resource.view.stub');
}
protected function resolveStub(string $name): string
{
return file_exists($customPath = $this->laravel->basePath("stubs/{$name}"))
? $customPath
: __DIR__."/../../../stubs/{$name}";
}
protected function getViewPath(): string
{
return resource_path('views/mcp/'.$this->getKebabName().'.blade.php');
}
protected function getKebabName(): string
{
return collect(explode('/', $this->getNameInput()))
->map(fn (string $segment) => Str::kebab($segment))
->implode('/');
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getOptions(): array
{
return [
['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource already exists'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Illuminate\Console\GeneratorCommand;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand(
name: 'make:mcp-prompt',
description: 'Create a new MCP prompt class'
)]
class MakePromptCommand extends GeneratorCommand
{
/**
* @var string
*/
protected $type = 'Prompt';
protected function getStub(): string
{
return file_exists($customPath = $this->laravel->basePath('stubs/mcp-prompt.stub'))
? $customPath
: __DIR__.'/../../../stubs/mcp-prompt.stub';
}
protected function getDefaultNamespace($rootNamespace): string
{
return "{$rootNamespace}\\Mcp\\Prompts";
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getOptions(): array
{
return [
['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the prompt already exists'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Illuminate\Console\GeneratorCommand;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand(
name: 'make:mcp-resource',
description: 'Create a new MCP resource class'
)]
class MakeResourceCommand extends GeneratorCommand
{
/**
* @var string
*/
protected $type = 'Resource';
protected function getStub(): string
{
return file_exists($customPath = $this->laravel->basePath('stubs/mcp-resource.stub'))
? $customPath
: __DIR__.'/../../../stubs/mcp-resource.stub';
}
protected function getDefaultNamespace($rootNamespace): string
{
return "{$rootNamespace}\\Mcp\\Resources";
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getOptions(): array
{
return [
['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource already exists'],
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Illuminate\Console\GeneratorCommand;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand(
name: 'make:mcp-server',
description: 'Create a new MCP server class'
)]
class MakeServerCommand extends GeneratorCommand
{
/**
* @var string
*/
protected $type = 'Server';
protected function getStub(): string
{
return file_exists($customPath = $this->laravel->basePath('stubs/mcp-server.stub'))
? $customPath
: __DIR__.'/../../../stubs/mcp-server.stub';
}
/**
* @param string $rootNamespace
*/
protected function getDefaultNamespace($rootNamespace): string
{
return "{$rootNamespace}\\Mcp\\Servers";
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getOptions(): array
{
return [
['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the server already exists'],
];
}
/**
* @param string $name
*
* @throws FileNotFoundException
*/
protected function buildClass($name): string
{
$stub = parent::buildClass($name);
$className = class_basename($name);
$serverDisplayName = trim((string) preg_replace('/(?<!^)([A-Z])/', ' $1', $className));
return str_replace('{{ serverDisplayName }}', $serverDisplayName, $stub);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Illuminate\Console\GeneratorCommand;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand(
name: 'make:mcp-tool',
description: 'Create a new MCP tool class'
)]
class MakeToolCommand extends GeneratorCommand
{
/**
* @var string
*/
protected $type = 'Tool';
protected function getStub(): string
{
return file_exists($customPath = $this->laravel->basePath('stubs/mcp-tool.stub'))
? $customPath
: __DIR__.'/../../../stubs/mcp-tool.stub';
}
/**
* @param string $rootNamespace
*/
protected function getDefaultNamespace($rootNamespace): string
{
return "{$rootNamespace}\\Mcp\\Tools";
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getOptions(): array
{
return [
['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the tool already exists'],
];
}
/**
* @param string $name
*
* @throws FileNotFoundException
*/
protected function buildClass($name): string
{
$stub = parent::buildClass($name);
$className = class_basename($name);
$title = Str::headline($className);
return str_replace(
'{{ title }}',
$title,
$stub,
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Console\Commands;
use Illuminate\Console\Command;
use Laravel\Mcp\Server\Registrar;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
#[AsCommand(
name: 'mcp:start',
description: 'Start the MCP Server for a given handle'
)]
class StartCommand extends Command
{
public function handle(Registrar $registrar): int
{
$handle = $this->argument('handle');
assert(is_string($handle));
$server = $registrar->getLocalServer($handle);
if ($server === null) {
$this->components->error("MCP Server with name [{$handle}] not found. Did you register it using [Mcp::local()]?");
return static::FAILURE;
}
$server();
return static::SUCCESS;
}
/**
* @return array<int, array<int, string|int>>
*/
protected function getArguments(): array
{
return [
['handle', InputArgument::REQUIRED, 'The handle of the MCP server to start.'],
];
}
}

11
vendor/laravel/mcp/src/Enums/Role.php vendored Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Enums;
enum Role: string
{
case Assistant = 'assistant';
case User = 'user';
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Events;
class SessionInitialized
{
/**
* @param array{name?: string, title?: string, version?: string}|null $clientInfo
* @param array<string, mixed>|null $clientCapabilities
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization
*/
public function __construct(
public readonly string $sessionId,
public readonly ?array $clientInfo,
public readonly ?string $protocolVersion,
public readonly ?array $clientCapabilities,
) {
//
}
/**
* Get the client name from clientInfo, if available.
*/
public function clientName(): ?string
{
return $this->clientInfo['name'] ?? null;
}
/**
* Get the client title from clientInfo, if available.
*/
public function clientTitle(): ?string
{
return $this->clientInfo['title'] ?? null;
}
/**
* Get the client version from clientInfo, if available.
*/
public function clientVersion(): ?string
{
return $this->clientInfo['version'] ?? null;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Exceptions;
use Exception;
class NotImplementedException extends Exception
{
public static function forMethod(string $class, string $method): static
{
return new static("The method [{$class}@{$method}] is not implemented yet.");
}
}

30
vendor/laravel/mcp/src/Facades/Mcp.php vendored Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Facades;
use Illuminate\Support\Facades\Facade;
use Laravel\Mcp\Server\Registrar;
/**
* @method static \Illuminate\Routing\Route web(string $route, string $serverClass)
* @method static void local(string $handle, string $serverClass)
* @method static callable|null getLocalServer(string $handle)
* @method static \Illuminate\Routing\Route|null getWebServer(string $route)
* @method static array servers()
* @method static void oauthRoutes(string $oauthPrefix = 'oauth')
* @method static array ensureMcpScope()
*
* @see \Laravel\Mcp\Server\Registrar
*/
class Mcp extends Facade
{
/**
* @return class-string<Registrar>
*/
protected static function getFacadeAccessor(): string
{
return Registrar::class;
}
}

146
vendor/laravel/mcp/src/Request.php vendored Normal file
View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Traits\Conditionable;
use Illuminate\Support\Traits\InteractsWithData;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Validation\ValidationException;
/**
* @implements Arrayable<string, mixed>
*/
class Request implements Arrayable
{
use Conditionable;
use InteractsWithData;
use Macroable;
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed>|null $meta
*/
public function __construct(
protected array $arguments = [],
protected ?string $sessionId = null,
protected ?array $meta = null,
protected ?string $uri = null,
) {
//
}
/**
* @param array<array-key, string>|array-key|null $keys
* @return array<string, mixed>
*/
public function all(mixed $keys = null): array
{
if (is_null($keys)) {
return $this->data();
}
return array_intersect_key($this->data(), array_flip(is_array($keys) ? $keys : func_get_args()));
}
protected function data(mixed $key = null, mixed $default = null): mixed
{
if (is_null($key)) {
return $this->arguments;
}
return $this->arguments[$key] ?? $default;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data($key, $default);
}
/**
* @param array<string,mixed> $data
*/
public function merge(array $data): static
{
$this->arguments = array_merge($this->arguments, $data);
return $this;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->arguments;
}
/**
* @param array<string, mixed> $rules
* @param array<string, mixed> $messages
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*
* @throws ValidationException
*/
public function validate(array $rules, array $messages = [], array $attributes = []): array
{
return Validator::validate($this->all(), $rules, $messages, $attributes);
}
public function user(?string $guard = null): ?Authenticatable
{
$auth = Container::getInstance()->make('auth');
return call_user_func($auth->userResolver(), $guard);
}
public function sessionId(): ?string
{
return $this->sessionId;
}
/**
* @return array<string, mixed>|null
*/
public function meta(): ?array
{
return $this->meta;
}
public function uri(): ?string
{
return $this->uri;
}
/**
* @param array<string, mixed> $arguments
*/
public function setArguments(array $arguments): void
{
$this->arguments = $arguments;
}
public function setSessionId(?string $sessionId): void
{
$this->sessionId = $sessionId;
}
/**
* @param array<string, mixed>|null $meta
*/
public function setMeta(?array $meta): void
{
$this->meta = $meta;
}
public function setUri(?string $uri): void
{
$this->uri = $uri;
}
}

186
vendor/laravel/mcp/src/Response.php vendored Normal file
View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Traits\Conditionable;
use Illuminate\Support\Traits\Macroable;
use InvalidArgumentException;
use JsonException;
use Laravel\Mcp\Enums\Role;
use Laravel\Mcp\Server\Content\Audio;
use Laravel\Mcp\Server\Content\Blob;
use Laravel\Mcp\Server\Content\Image;
use Laravel\Mcp\Server\Content\Notification;
use Laravel\Mcp\Server\Content\Text;
use Laravel\Mcp\Server\Contracts\Content;
use League\Flysystem\UnableToReadFile;
class Response
{
use Conditionable;
use Macroable;
protected function __construct(
protected Content $content,
protected Role $role = Role::User,
protected bool $isError = false,
) {
//
}
/**
* @param array<string, mixed> $params
*/
public static function notification(string $method, array $params = []): static
{
return new static(new Notification($method, $params));
}
public static function text(string $text): static
{
return new static(new Text($text));
}
public static function html(string $path): static
{
$path = str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path) ? $path : resource_path($path);
if (! file_exists($path)) {
throw new InvalidArgumentException("File not found at path [{$path}].");
}
return static::text((string) file_get_contents($path));
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $mergeData
*/
public static function view(string $view, array $data = [], array $mergeData = []): static
{
return static::text(view($view, $data, $mergeData)->render());
}
/**
* @internal
*
* @throws JsonException
*/
public static function json(mixed $content): static
{
return static::text(json_encode($content, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
public static function blob(string $content): static
{
return new static(new Blob($content));
}
/**
* @param array<string, mixed> $response
*/
public static function structured(array $response): ResponseFactory
{
if ($response === []) {
throw new InvalidArgumentException('Structured content cannot be empty.');
}
try {
$json = json_encode($response, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (JsonException $jsonException) {
throw new InvalidArgumentException("Invalid structured content: {$jsonException->getMessage()}", 0, $jsonException);
}
$content = Response::text($json);
return (new ResponseFactory($content))->withStructuredContent($response);
}
public static function error(string $text): static
{
return new static(new Text($text), isError: true);
}
public function content(): Content
{
return $this->content;
}
/**
* @param Response|array<int, Response> $responses
*/
public static function make(Response|array $responses): ResponseFactory
{
return new ResponseFactory($responses);
}
/**
* @param array<string, mixed>|string $meta
*/
public function withMeta(array|string $meta, mixed $value = null): static
{
$this->content->setMeta($meta, $value);
return $this;
}
public static function audio(string $data, string $mimeType = 'audio/wav'): static
{
return new static(new Audio($data, $mimeType));
}
public static function image(string $data, string $mimeType = 'image/png'): static
{
return new static(new Image($data, $mimeType));
}
public static function fromStorage(string $path, ?string $disk = null, ?string $mimeType = null): static
{
/** @var FilesystemAdapter $storage */
$storage = Storage::disk($disk);
try {
$data = $storage->get($path);
} catch (UnableToReadFile $unableToReadFile) {
throw new InvalidArgumentException("File not found at path [{$path}].", 0, $unableToReadFile);
}
if ($data === null) {
throw new InvalidArgumentException("File not found at path [{$path}].");
}
$mimeType ??= $storage->mimeType($path) ?: throw new InvalidArgumentException(
"Unable to determine MIME type for [{$path}].",
);
return match (true) {
str_starts_with($mimeType, 'image/') => static::image($data, $mimeType),
str_starts_with($mimeType, 'audio/') => static::audio($data, $mimeType),
default => throw new InvalidArgumentException("Unsupported MIME type [{$mimeType}] for [{$path}]."),
};
}
public function asAssistant(): static
{
return new static($this->content, Role::Assistant, $this->isError);
}
public function isNotification(): bool
{
return $this->content instanceof Notification;
}
public function isError(): bool
{
return $this->isError;
}
public function role(): Role
{
return $this->role;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Traits\Conditionable;
use Illuminate\Support\Traits\Macroable;
use InvalidArgumentException;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Concerns\HasStructuredContent;
class ResponseFactory
{
use Conditionable;
use HasMeta;
use HasStructuredContent;
use Macroable;
/**
* @var Collection<int, Response>
*/
protected Collection $responses;
/**
* @param Response|array<int, Response> $responses
*/
public function __construct(Response|array $responses)
{
$wrapped = Arr::wrap($responses);
foreach ($wrapped as $index => $response) {
if (! $response instanceof Response) {
throw new InvalidArgumentException(
"Invalid response type at index {$index}: Expected ".Response::class.', but received '.get_debug_type($response).'.'
);
}
}
$this->responses = collect($wrapped);
}
/**
* @param string|array<string, mixed> $meta
*/
public function withMeta(string|array $meta, mixed $value = null): static
{
$this->setMeta($meta, $value);
return $this;
}
/**
* @param array<string, mixed> $structuredContent
*/
public function withStructuredContent(array $structuredContent): static
{
$this->setStructuredContent($structuredContent);
return $this;
}
/**
* @return Collection<int, Response>
*/
public function responses(): Collection
{
return $this->responses;
}
/**
* @return array<string, mixed>|null
*/
public function getMeta(): ?array
{
return $this->meta;
}
/**
* @return array<string, mixed>|null
*/
public function getStructuredContent(): ?array
{
return $this->structuredContent;
}
}

347
vendor/laravel/mcp/src/Server.php vendored Normal file
View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp;
use Illuminate\Container\Container;
use Illuminate\Support\Str;
use Laravel\Mcp\Events\SessionInitialized;
use Laravel\Mcp\Server\AppResource;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
use Laravel\Mcp\Server\Concerns\ReadsAttributes;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Contracts\Transport;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\CallTool;
use Laravel\Mcp\Server\Methods\CompletionComplete;
use Laravel\Mcp\Server\Methods\GetPrompt;
use Laravel\Mcp\Server\Methods\Initialize;
use Laravel\Mcp\Server\Methods\ListPrompts;
use Laravel\Mcp\Server\Methods\ListResources;
use Laravel\Mcp\Server\Methods\ListResourceTemplates;
use Laravel\Mcp\Server\Methods\ListTools;
use Laravel\Mcp\Server\Methods\Ping;
use Laravel\Mcp\Server\Methods\ReadResource;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Testing\PendingTestResponse;
use Laravel\Mcp\Server\Testing\TestResponse;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Transport\JsonRpcNotification;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use stdClass;
use Throwable;
/**
* @mixin PendingTestResponse
*/
abstract class Server
{
use ReadsAttributes;
public const CAPABILITY_TOOLS = 'tools';
public const CAPABILITY_RESOURCES = 'resources';
public const CAPABILITY_PROMPTS = 'prompts';
public const CAPABILITY_COMPLETIONS = 'completions';
public const CAPABILITY_UI = 'io.modelcontextprotocol/ui';
protected string $name = 'Laravel MCP Server';
protected string $version = '0.0.1';
protected string $instructions = <<<'MARKDOWN'
This MCP server lets AI agents interact with our Laravel application.
MARKDOWN;
/**
* @var array<int, string>
*/
protected array $supportedProtocolVersion = [
'2025-11-25',
'2025-06-18',
'2025-03-26',
'2024-11-05',
];
/**
* @var array<string, array<string, bool>|stdClass|string>
*/
protected array $capabilities = [
self::CAPABILITY_TOOLS => [
'listChanged' => false,
],
self::CAPABILITY_RESOURCES => [
'listChanged' => false,
],
self::CAPABILITY_PROMPTS => [
'listChanged' => false,
],
];
/**
* @var array<int, Tool|class-string<Tool>>
*/
protected array $tools = [];
/**
* @var array<int, Resource|class-string<Resource>>
*/
protected array $resources = [];
/**
* @var array<int, Prompt|class-string<Prompt>>
*/
protected array $prompts = [];
public int $maxPaginationLength = 50;
public int $defaultPaginationLength = 15;
/**
* @var array<string, class-string<Method>>
*/
protected array $methods = [
'tools/list' => ListTools::class,
'tools/call' => CallTool::class,
'resources/list' => ListResources::class,
'resources/read' => ReadResource::class,
'resources/templates/list' => ListResourceTemplates::class,
'prompts/list' => ListPrompts::class,
'prompts/get' => GetPrompt::class,
'completion/complete' => CompletionComplete::class,
'ping' => Ping::class,
];
public function __construct(
protected Transport $transport,
) {
//
}
/**
* Add or modify a server capability.
*
* Using dot notation like "feature.enabled" will create a nested capability array.
* Passing a single key like "anotherFeature" will register an empty object capability.
*/
public function addCapability(string $key, bool $value = true): void
{
if (str_contains($key, '.')) {
[$root, $child] = explode('.', $key, 2);
$existing = $this->capabilities[$root] ?? [];
if (! is_array($existing)) {
$existing = [];
}
$existing[$child] = $value;
$this->capabilities[$root] = $existing;
return;
}
// Represent empty capability as an object when JSON encoded
$this->capabilities[$key] = (object) [];
}
/**
* Register a custom JSON-RPC method handler.
*
* @param class-string<Method> $handler
*/
public function addMethod(string $method, string $handler): void
{
$this->methods[$method] = $handler;
}
public function start(): void
{
$this->boot();
$this->detectUiCapability();
$this->transport->onReceive($this->handle(...));
}
protected function boot(): void
{
//
}
public function handle(string $rawMessage): void
{
$context = $this->createContext();
try {
$jsonRequest = json_decode($rawMessage, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new JsonRpcException('Parse error: Invalid JSON was received by the server.', -32700);
}
$request = isset($jsonRequest['id'])
? JsonRpcRequest::from($jsonRequest, $this->transport->sessionId())
: JsonRpcNotification::from($jsonRequest);
if ($request instanceof JsonRpcNotification) {
return;
}
if ($request->method === 'initialize') {
$this->handleInitializeMessage($request, $context);
return;
}
if (! isset($this->methods[$request->method])) {
throw new JsonRpcException(
"The method [{$request->method}] was not found.",
-32601,
$request->id,
);
}
$this->handleMessage($request, $context);
} catch (JsonRpcException $e) {
$this->transport->send($e->toJsonRpcResponse()->toJson());
} catch (Throwable $e) {
report($e);
$config = Container::getInstance()->make('config');
if ($config->get('app.debug', false)) {
throw $e;
}
$jsonRpcResponse = JsonRpcResponse::error(
$request->id ?? null,
-32603,
'Something went wrong while processing the request.',
);
$this->transport->send($jsonRpcResponse->toJson());
}
}
public function createContext(): ServerContext
{
$name = $this->resolveAttribute(Name::class);
$version = $this->resolveAttribute(Version::class);
$instructions = $this->resolveAttribute(Instructions::class);
return new ServerContext(
supportedProtocolVersions: $this->supportedProtocolVersion,
serverCapabilities: $this->capabilities,
serverName: $name !== null ? $name->value : $this->name,
serverVersion: $version !== null ? $version->value : $this->version,
instructions: $instructions !== null ? $instructions->value : $this->instructions,
maxPaginationLength: $this->maxPaginationLength,
defaultPaginationLength: $this->defaultPaginationLength,
tools: $this->tools,
resources: $this->resources,
prompts: $this->prompts,
);
}
/**
* @throws JsonRpcException
*/
protected function handleMessage(JsonRpcRequest $request, ServerContext $context): void
{
$response = $this->runMethodHandle($request, $context);
if (! is_iterable($response)) {
$this->transport->send($response->toJson());
return;
}
$this->transport->stream(function () use ($response): void {
foreach ($response as $message) {
$this->transport->send($message->toJson());
}
});
}
/**
* @return iterable<JsonRpcResponse>|JsonRpcResponse
*
* @throws JsonRpcException
*/
protected function runMethodHandle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse
{
$container = Container::getInstance();
/** @var Method $methodClass */
$methodClass = $container->make(
$this->methods[$request->method],
);
$container->instance('mcp.request', $request->toRequest());
try {
$response = $methodClass->handle($request, $context);
} finally {
$container->forgetInstance('mcp.request');
}
return $response;
}
protected function handleInitializeMessage(JsonRpcRequest $request, ServerContext $context): void
{
$response = (new Initialize)->handle($request, $context);
$sessionId = $this->generateSessionId();
Container::getInstance()->make('events')->dispatch(new SessionInitialized(
sessionId: $sessionId,
clientInfo: $request->params['clientInfo'] ?? null,
protocolVersion: $request->params['protocolVersion'] ?? null,
clientCapabilities: $request->params['capabilities'] ?? null,
));
$this->transport->send($response->toJson(), $sessionId);
}
protected function generateSessionId(): string
{
return Str::uuid()->toString();
}
protected function detectUiCapability(): void
{
if (array_key_exists(self::CAPABILITY_UI, $this->capabilities)) {
return;
}
foreach ($this->resources as $resource) {
if (is_subclass_of($resource, AppResource::class)) {
$this->addCapability(self::CAPABILITY_UI);
return;
}
}
}
/**
* @param array<array-key, mixed> $arguments
*/
public static function __callStatic(string $name, array $arguments): PendingTestResponse|TestResponse
{
$pendingTestResponse = new PendingTestResponse(
Container::getInstance(),
static::class,
);
return $pendingTestResponse->$name(...$arguments);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Annotations;
use Laravel\Mcp\Server\Contracts\Annotation as AnnotationContract;
abstract class Annotation implements AnnotationContract
{
//
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Annotations;
use Attribute;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Laravel\Mcp\Enums\Role;
#[Attribute(Attribute::TARGET_CLASS)]
class Audience extends Annotation
{
/** @var array<int,string> */
public array $value;
/**
* @param Role|array<int, Role> $roles
*/
public function __construct(Role|array $roles)
{
$roles = Arr::wrap($roles);
foreach ($roles as $role) {
if (! $role instanceof Role) {
throw new InvalidArgumentException(
'All values of '.Audience::class.' attributes must be instances of '.Role::class
);
}
}
$this->value = array_map(fn (Role $role) => $role->value, $roles);
}
public function key(): string
{
return 'audience';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Annotations;
use Attribute;
use DateTimeImmutable;
use Exception;
use InvalidArgumentException;
#[Attribute(Attribute::TARGET_CLASS)]
class LastModified extends Annotation
{
public function __construct(public string $value)
{
try {
new DateTimeImmutable($value);
} catch (Exception $exception) {
throw new InvalidArgumentException("LastModified must be a valid ISO 8601 timestamp, got '{$value}'", $exception->getCode(), previous: $exception);
}
}
public function key(): string
{
return 'lastModified';
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Annotations;
use Attribute;
use InvalidArgumentException;
#[Attribute(Attribute::TARGET_CLASS)]
class Priority extends Annotation
{
public function __construct(public float $value)
{
if ($value < 0.0 || $value > 1.0) {
throw new InvalidArgumentException(
"Priority must be between 0.0 and 1.0, got {$value}"
);
}
}
public function key(): string
{
return 'priority';
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\AppMeta as AppMetaAttribute;
use Laravel\Mcp\Server\Ui\AppMeta;
use Laravel\Mcp\Server\Ui\Enums\Library;
abstract class AppResource extends Resource
{
protected string $mimeType = 'text/html;profile=mcp-app';
protected string $defaultUriScheme = 'ui';
public function appMeta(): AppMeta
{
$attribute = $this->resolveAttribute(AppMetaAttribute::class);
return $attribute?->toAppMeta() ?? new AppMeta;
}
/**
* @return array<string, mixed>
*/
public function resolvedAppMeta(): array
{
$appMeta = $this->appMeta()->toArray();
if (! isset($appMeta['domain'])) {
$domain = parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: null;
if ($domain !== null) {
$appMeta['domain'] = $domain;
}
}
return $appMeta;
}
public function libraryScripts(): string
{
return implode("\n", array_map(
fn (Library $lib): string => implode("\n", $lib->scriptTags()),
$this->appMeta()->getLibraries(),
));
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$data = parent::toArray();
$appMeta = $this->resolvedAppMeta();
if ($appMeta !== []) {
$data['_meta'] = array_merge($data['_meta'] ?? [], ['ui' => $appMeta]);
}
return $data;
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
use Laravel\Mcp\Server\Ui\AppMeta as AppMetaData;
use Laravel\Mcp\Server\Ui\Csp;
use Laravel\Mcp\Server\Ui\Enums\Library;
use Laravel\Mcp\Server\Ui\Enums\Permission;
use Laravel\Mcp\Server\Ui\Permissions;
#[Attribute(Attribute::TARGET_CLASS)]
class AppMeta
{
/**
* @param array<int, string>|null $connectDomains Domains the app may connect to via fetch, XHR, or WebSocket (CSP connect-src).
* @param array<int, string>|null $resourceDomains Domains the app may load images, scripts, styles, and fonts from (CSP default-src).
* @param array<int, string>|null $frameDomains Domains the app may embed as nested iframes (CSP frame-src).
* @param array<int, string>|null $baseUriDomains Allowed URLs for the document's base element (CSP base-uri).
* @param array<int, Permission>|null $permissions
* @param array<int, Library> $libraries
*/
public function __construct(
public readonly ?array $connectDomains = null,
public readonly ?array $resourceDomains = null,
public readonly ?array $frameDomains = null,
public readonly ?array $baseUriDomains = null,
public readonly ?array $permissions = null,
public readonly ?bool $prefersBorder = null,
public readonly ?string $domain = null,
public readonly array $libraries = [],
) {
//
}
public function toAppMeta(): AppMetaData
{
$meta = AppMetaData::make();
if (($csp = $this->getCsp()) instanceof Csp) {
$meta->csp($csp);
}
if ($this->permissions !== null) {
$meta->permissions(Permissions::make()->allow(...$this->permissions));
}
if ($this->prefersBorder !== null) {
$meta->prefersBorder($this->prefersBorder);
}
if ($this->domain !== null) {
$meta->domain($this->domain);
}
if ($this->libraries !== []) {
$meta->libraries(...$this->libraries);
}
return $meta;
}
protected function getCsp(): ?Csp
{
if (
$this->connectDomains === null
&& $this->resourceDomains === null
&& $this->frameDomains === null
&& $this->baseUriDomains === null
) {
return null;
}
return Csp::make()
->connectDomains($this->connectDomains ?? [])
->resourceDomains($this->resourceDomains ?? [])
->frameDomains($this->frameDomains ?? [])
->baseUriDomains($this->baseUriDomains ?? []);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Description extends ServerAttribute {}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Instructions extends ServerAttribute {}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class MimeType extends ServerAttribute {}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Name extends ServerAttribute {}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
use Laravel\Mcp\Server\Ui\Enums\Visibility;
#[Attribute(Attribute::TARGET_CLASS)]
class RendersApp
{
/**
* @param class-string $resource
* @param array<int, Visibility> $visibility
*/
public function __construct(
public string $resource,
public array $visibility = [Visibility::Model, Visibility::App],
) {
//
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
abstract class ServerAttribute
{
public function __construct(public string $value) {}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Title extends ServerAttribute {}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Uri extends ServerAttribute {}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Version extends ServerAttribute {}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Completions;
class ArrayCompletionResponse extends CompletionResponse
{
/**
* @param array<int, string> $items
*/
public function __construct(private array $items)
{
parent::__construct([]);
}
public function resolve(string $value): DirectCompletionResponse
{
$filtered = CompletionHelper::filterByPrefix($this->items, $value);
$hasMore = count($filtered) > self::MAX_VALUES;
$truncated = array_slice($filtered, 0, self::MAX_VALUES);
return new DirectCompletionResponse($truncated, $hasMore);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Completions;
use Illuminate\Support\Str;
class CompletionHelper
{
/**
* @param array<string> $items
* @return array<string>
*/
public static function filterByPrefix(array $items, string $prefix): array
{
if ($prefix === '') {
return $items;
}
$prefixLower = Str::lower($prefix);
return array_values(array_filter(
$items,
fn (string $item) => Str::startsWith(Str::lower($item), $prefixLower)
));
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Completions;
use Illuminate\Contracts\Support\Arrayable;
use InvalidArgumentException;
use UnitEnum;
/**
* @implements Arrayable<string, mixed>
*/
abstract class CompletionResponse implements Arrayable
{
protected const MAX_VALUES = 100;
/**
* @param array<int, string> $values
*/
public function __construct(
protected array $values,
protected bool $hasMore = false,
) {
if (count($values) > self::MAX_VALUES) {
throw new InvalidArgumentException(
sprintf('Completion values cannot exceed %d items (received %d)', self::MAX_VALUES, count($values))
);
}
}
public static function empty(): CompletionResponse
{
return new DirectCompletionResponse([]);
}
/**
* @param array<int, string>|class-string<UnitEnum> $items
*/
public static function match(array|string $items): CompletionResponse
{
if (is_string($items)) {
return new EnumCompletionResponse($items);
}
return new ArrayCompletionResponse($items);
}
/**
* @param array<int, string>|string $items
*/
public static function result(array|string $items): CompletionResponse
{
if (is_array($items)) {
$hasMore = count($items) > self::MAX_VALUES;
$truncated = array_slice($items, 0, self::MAX_VALUES);
return new DirectCompletionResponse($truncated, $hasMore);
}
return new DirectCompletionResponse([$items], false);
}
abstract public function resolve(string $value): CompletionResponse;
/**
* @return array<int, string>
*/
public function values(): array
{
return $this->values;
}
public function hasMore(): bool
{
return $this->hasMore;
}
/**
* @return array{values: array<int, string>, total: int, hasMore: bool}
*/
public function toArray(): array
{
return [
'values' => $this->values,
'total' => count($this->values),
'hasMore' => $this->hasMore,
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Completions;
class DirectCompletionResponse extends CompletionResponse
{
public function resolve(string $value): DirectCompletionResponse
{
return $this;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Completions;
use BackedEnum;
use InvalidArgumentException;
use UnitEnum;
class EnumCompletionResponse extends CompletionResponse
{
/**
* @param class-string<UnitEnum> $enumClass
*/
public function __construct(private string $enumClass)
{
if (! enum_exists($enumClass)) {
throw new InvalidArgumentException("Class [{$enumClass}] is not an enum.");
}
parent::__construct([]);
}
public function resolve(string $value): DirectCompletionResponse
{
$enumValues = array_map(
fn (UnitEnum $case): string => $case instanceof BackedEnum ? (string) $case->value : $case->name,
$this->enumClass::cases()
);
$filtered = CompletionHelper::filterByPrefix($enumValues, $value);
$hasMore = count($filtered) > self::MAX_VALUES;
$truncated = array_slice($filtered, 0, self::MAX_VALUES);
return new DirectCompletionResponse($truncated, $hasMore);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Concerns;
use Illuminate\Support\Collection;
use InvalidArgumentException;
use Laravel\Mcp\Server\Contracts\Annotation as AnnotationContract;
use ReflectionAttribute;
use ReflectionClass;
trait HasAnnotations
{
/**
* @return array<string, mixed>
*/
public function annotations(): array
{
$reflection = new ReflectionClass($this);
/** @var Collection<int, AnnotationContract> $annotations */
$annotations = collect($reflection->getAttributes())
->map(fn (ReflectionAttribute $attributeReflection): object => $attributeReflection->newInstance())
->filter(fn (object $attribute): bool => $attribute instanceof AnnotationContract)
->values();
// @phpstan-ignore argument.templateType
return $annotations
->each(function (AnnotationContract $attribute): void {
$this->validateAnnotationUsage($attribute);
})
->mapWithKeys(fn (AnnotationContract $attribute): array => [ // @phpstan-ignore argument.templateType
$attribute->key() => $attribute->value, // @phpstan-ignore property.notFound
])
->all();
}
private function validateAnnotationUsage(AnnotationContract $attribute): void
{
$allowedAnnotations = $this->allowedAnnotations();
foreach ($allowedAnnotations as $allowedAnnotationClass) {
if ($attribute instanceof $allowedAnnotationClass) {
return;
}
}
$allowedClasses = empty($allowedAnnotations)
? 'none'
: implode(', ', $allowedAnnotations);
throw new InvalidArgumentException(
sprintf(
'Annotation [%s] cannot be used on [%s]. Allowed annotation types: [%s]',
$attribute::class,
$this::class,
$allowedClasses
)
);
}
/**
* @return array<int, class-string>
*/
protected function allowedAnnotations(): array
{
return [];
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Concerns;
use InvalidArgumentException;
trait HasMeta
{
/**
* @var array<string, mixed>|null
*/
protected ?array $meta = null;
/**
* @param array<string, mixed>|string $meta
*/
public function setMeta(array|string $meta, mixed $value = null): void
{
$this->meta ??= [];
if (! is_array($meta)) {
if (is_null($value)) {
throw new InvalidArgumentException('Value is required when using key-value signature.');
}
$this->meta[$meta] = $value;
return;
}
$this->meta = array_merge($this->meta, $meta);
}
/**
* @template T of array<string, mixed>
*
* @param T $baseArray
* @return T&array{_meta?: array<string, mixed>}
*/
public function mergeMeta(array $baseArray): array
{
return ($meta = $this->meta)
? [...$baseArray, '_meta' => $meta]
: $baseArray;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Concerns;
trait HasStructuredContent
{
/**
* @var array<string, mixed>|null
*/
protected ?array $structuredContent = null;
/**
* @param array<string, mixed> $structuredContent
*/
public function setStructuredContent(array $structuredContent): void
{
$this->structuredContent ??= [];
$this->structuredContent = array_merge($this->structuredContent, $structuredContent);
}
/**
* @param array<string, mixed> $baseArray
* @return array<string, mixed>
*/
public function mergeStructuredContent(array $baseArray): array
{
if ($this->structuredContent === null) {
return $baseArray;
}
return array_merge($baseArray, ['structuredContent' => $this->structuredContent]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Concerns;
use ReflectionClass;
trait ReadsAttributes
{
/**
* @var array<string, object|null>
*/
protected static array $attributeCache = [];
/**
* @template T of object
*
* @param class-string<T> $attributeClass
* @return T|null
*/
protected function resolveAttribute(string $attributeClass): mixed
{
$cacheKey = static::class.'@'.$attributeClass;
if (array_key_exists($cacheKey, static::$attributeCache)) {
return static::$attributeCache[$cacheKey]; // @phpstan-ignore return.type
}
$reflection = new ReflectionClass($this);
do {
$attributes = $reflection->getAttributes($attributeClass);
if ($attributes !== []) {
return static::$attributeCache[$cacheKey] = $attributes[0]->newInstance();
}
} while ($reflection = $reflection->getParentClass());
return static::$attributeCache[$cacheKey] = null;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Content;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Contracts\Content;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
class Audio implements Content
{
use HasMeta;
public function __construct(protected string $data, protected string $mimeType = 'audio/wav')
{
//
}
/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array
{
return $this->mergeMeta([
'blob' => base64_encode($this->data),
'uri' => $resource->uri(),
'mimeType' => $this->mimeType,
]);
}
public function __toString(): string
{
return $this->data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->mergeMeta([
'type' => 'audio',
'data' => base64_encode($this->data),
'mimeType' => $this->mimeType,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Content;
use InvalidArgumentException;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Contracts\Content;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
class Blob implements Content
{
use HasMeta;
public function __construct(protected string $content)
{
//
}
/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array
{
throw new InvalidArgumentException(
'Blob content may not be used in tools.',
);
}
/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array
{
throw new InvalidArgumentException(
'Blob content may not be used in prompts.',
);
}
/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array
{
return $this->mergeMeta([
'blob' => base64_encode($this->content),
'uri' => $resource->uri(),
'mimeType' => $resource->mimeType(),
]);
}
public function __toString(): string
{
return $this->content;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->mergeMeta([
'type' => 'blob',
'blob' => $this->content,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Content;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Contracts\Content;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
class Image implements Content
{
use HasMeta;
public function __construct(protected string $data, protected string $mimeType = 'image/png')
{
//
}
/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array
{
return $this->mergeMeta([
'blob' => base64_encode($this->data),
'uri' => $resource->uri(),
'mimeType' => $this->mimeType,
]);
}
public function __toString(): string
{
return $this->data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->mergeMeta([
'type' => 'image',
'data' => base64_encode($this->data),
'mimeType' => $this->mimeType,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Content;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Contracts\Content;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
class Notification implements Content
{
use HasMeta;
/**
* @param array<string, mixed> $params
*/
public function __construct(protected string $method, protected array $params)
{
//
}
/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array
{
return $this->toArray();
}
public function __toString(): string
{
return $this->method;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$params = $this->params;
if ($this->meta !== null && $this->meta !== [] && ! isset($params['_meta'])) {
$params['_meta'] = $this->meta;
}
return [
'method' => $this->method,
'params' => $params,
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Content;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Contracts\Content;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
class Text implements Content
{
use HasMeta;
public function __construct(protected string $text)
{
//
}
/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array
{
return $this->toArray();
}
/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array
{
return $this->mergeMeta([
'text' => $this->text,
'uri' => $resource->uri(),
'mimeType' => $resource->mimeType(),
]);
}
public function __toString(): string
{
return $this->text;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->mergeMeta([
'type' => 'text',
'text' => $this->text,
]);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
interface Annotation
{
public function key(): string;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
use Laravel\Mcp\Server\Completions\CompletionResponse;
interface Completable
{
/**
* @param array<string, mixed> $context
*/
public function complete(string $argument, string $value, array $context): CompletionResponse;
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
use Illuminate\Contracts\Support\Arrayable;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
use Stringable;
/**
* @extends Arrayable<string, mixed>
*/
interface Content extends Arrayable, Stringable
{
/**
* @return array<string, mixed>
*/
public function toTool(Tool $tool): array;
/**
* @return array<string, mixed>
*/
public function toPrompt(Prompt $prompt): array;
/**
* @return array<string, mixed>
*/
public function toResource(Resource $resource): array;
/**
* @param array<string, mixed>|string $meta
*/
public function setMeta(array|string $meta, mixed $value = null): void;
public function __toString(): string;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
interface Errable
{
//
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
use Laravel\Mcp\Support\UriTemplate;
interface HasUriTemplate
{
/**
* Get the URI pattern for the resource template.
*/
public function uriTemplate(): UriTemplate;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
interface Method
{
/**
* @return iterable<JsonRpcResponse>|JsonRpcResponse
*
* @throws JsonRpcException
*/
public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Contracts;
use Closure;
interface Transport
{
public function onReceive(Closure $handler): void;
public function run(); // @phpstan-ignore-line
public function send(string $message, ?string $sessionId = null): void;
public function sessionId(): ?string;
public function stream(Closure $stream): void;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Exceptions;
use Exception;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class JsonRpcException extends Exception
{
/**
* @param array<string, mixed>|null $data
*/
public function __construct(
string $message,
int $code,
protected mixed $requestId = null,
protected ?array $data = null
) {
parent::__construct($message, $code);
}
public function toJsonRpcResponse(): JsonRpcResponse
{
return JsonRpcResponse::error(
id: $this->requestId,
code: $this->getCode(),
message: $this->getMessage(),
data: $this->data,
);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Http\Controllers;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class OAuthRegisterController
{
/**
* Register a new OAuth client for a third-party application.
*
* @throws BindingResolutionException
*/
public function __invoke(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'client_name' => ['nullable', 'string', 'min:1', 'max:255', 'required_without:name'],
'name' => ['nullable', 'string', 'min:1', 'max:255', 'required_without:client_name'],
'redirect_uris' => ['required', 'array', 'min:1'],
'redirect_uris.*' => ['required', 'string', function (string $attribute, $value, $fail): void {
if (! $this->isValidRedirectUri($value)) {
$fail($attribute.' is not a valid URL.');
return;
}
if (! in_array(parse_url($value, PHP_URL_SCHEME), ['http', 'https'], true)) {
return;
}
if (in_array('*', config('mcp.redirect_domains', []), true)) {
return;
}
if ($this->hasLocalhostDomain() && $this->isLocalhostUrl($value)) {
return;
}
if (! Str::startsWith($value, $this->allowedDomains())) {
$fail($attribute.' is not a permitted redirect domain.');
}
}],
]);
if ($validator->fails()) {
$errors = $validator->errors();
$isRedirectError = collect($errors->keys())->contains(
fn (string $key): bool => str_starts_with($key, 'redirect_uris')
);
return response()->json([
'error' => $isRedirectError ? 'invalid_redirect_uri' : 'invalid_client_metadata',
'error_description' => $errors->first(),
], 400);
}
$validated = $validator->validated();
if (class_exists('Laravel\Passport\ClientRepository') === false) {
return response()->json([
'error' => 'server_error',
'error_description' => 'OAuth support (Passport) is not installed.',
], 500);
}
$clients = Container::getInstance()->make(
'Laravel\Passport\ClientRepository'
);
$client = $clients->createAuthorizationCodeGrantClient(
name: $validated['client_name'] ?? $validated['name'],
redirectUris: $validated['redirect_uris'],
confidential: false,
user: null,
enableDeviceFlow: false,
);
return response()->json([
'client_id' => (string) $client->id,
'grant_types' => $client->grant_types,
'response_types' => ['code'],
'redirect_uris' => $client->redirect_uris,
'scope' => 'mcp:use',
'token_endpoint_auth_method' => 'none',
]);
}
protected function isValidRedirectUri(string $value): bool
{
$scheme = parse_url($value, PHP_URL_SCHEME);
if (! is_string($scheme)) {
return false;
}
if (in_array($scheme, ['http', 'https'], true)) {
return Str::isUrl($value, ['http', 'https']);
}
/** @var array<int, string> */
$allowedSchemes = config('mcp.custom_schemes', []);
$host = parse_url($value, PHP_URL_HOST);
return in_array($scheme, $allowedSchemes, true) && is_string($host) && $host !== '';
}
protected function isLocalhostUrl(string $url): bool
{
return Str::startsWith($url, [
'http://localhost:',
'http://localhost/',
'http://127.0.0.1:',
'http://127.0.0.1/',
'http://[::1]:',
'http://[::1]/',
]);
}
/**
* Get the allowed redirect domains.
*
* @return array<int, string>
*/
protected function allowedDomains(): array
{
/** @var array<int, string> */
$allowedDomains = config('mcp.redirect_domains', []);
return collect($allowedDomains)
->map(fn (string $domain): string => Str::endsWith($domain, '/')
? $domain
: "{$domain}/"
)
->all();
}
private function hasLocalhostDomain(): bool
{
/** @var array<int, string> */
$domains = config('mcp.redirect_domains', []);
return collect($domains)->contains(fn (string $domain): bool => in_array(
rtrim(Str::after($domain, '://'), '/'),
['localhost', '127.0.0.1', '[::1]'],
true,
));
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Laravel\Mcp\Console\Commands\InspectorCommand;
use Laravel\Mcp\Console\Commands\MakeAppResourceCommand;
use Laravel\Mcp\Console\Commands\MakePromptCommand;
use Laravel\Mcp\Console\Commands\MakeResourceCommand;
use Laravel\Mcp\Console\Commands\MakeServerCommand;
use Laravel\Mcp\Console\Commands\MakeToolCommand;
use Laravel\Mcp\Console\Commands\StartCommand;
use Laravel\Mcp\Request;
class McpServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registrar::class, fn (): Registrar => new Registrar);
$this->app->singleton('mcp.sdk', fn (): string => (string) file_get_contents(__DIR__.'/../../resources/js/mcp-sdk.min.js'));
$this->mergeConfigFrom(__DIR__.'/../../config/mcp.php', 'mcp');
}
public function boot(): void
{
$this->registerMcpScope();
$this->registerRoutes();
$this->registerContainerCallbacks();
$this->registerViews();
if ($this->app->runningInConsole()) {
$this->registerCommands();
$this->registerPublishing();
}
}
protected function registerPublishing(): void
{
$this->publishes([
__DIR__.'/../../routes/ai.php' => base_path('routes/ai.php'),
], 'ai-routes');
$this->publishes([
__DIR__.'/../../resources/views/mcp/authorize.blade.php' => resource_path('views/mcp/authorize.blade.php'),
__DIR__.'/../../resources/views/mcp/components/app.blade.php' => resource_path('views/vendor/mcp/components/app.blade.php'),
], 'mcp-views');
$this->publishes([
__DIR__.'/../../stubs/mcp-prompt.stub' => base_path('stubs/mcp-prompt.stub'),
__DIR__.'/../../stubs/mcp-resource.stub' => base_path('stubs/mcp-resource.stub'),
__DIR__.'/../../stubs/mcp-server.stub' => base_path('stubs/mcp-server.stub'),
__DIR__.'/../../stubs/mcp-tool.stub' => base_path('stubs/mcp-tool.stub'),
__DIR__.'/../../stubs/mcp-app-resource.stub' => base_path('stubs/mcp-app-resource.stub'),
__DIR__.'/../../stubs/mcp-app-resource.view.stub' => base_path('stubs/mcp-app-resource.view.stub'),
], 'mcp-stubs');
$this->publishes([
__DIR__.'/../../config/mcp.php' => config_path('mcp.php'),
], 'mcp-config');
}
protected function registerRoutes(): void
{
$path = base_path('routes/ai.php');
if (! file_exists($path)) {
return;
}
if (! $this->app->runningInConsole() && $this->app->routesAreCached()) {
return;
}
Route::group([], $path);
}
protected function registerContainerCallbacks(): void
{
$this->app->resolving(Request::class, function (Request $request, $app): void {
if ($app->bound('mcp.request')) {
/** @var Request $currentRequest */
$currentRequest = $app->make('mcp.request');
$request->setArguments($currentRequest->all());
$request->setSessionId($currentRequest->sessionId());
$request->setMeta($currentRequest->meta());
}
});
}
protected function registerCommands(): void
{
$this->commands([
StartCommand::class,
MakeServerCommand::class,
MakeToolCommand::class,
MakePromptCommand::class,
MakeResourceCommand::class,
MakeAppResourceCommand::class,
InspectorCommand::class,
]);
}
protected function registerViews(): void
{
$this->loadViewsFrom(__DIR__.'/../../resources/views/mcp', 'mcp');
}
protected function registerMcpScope(): void
{
$this->app->booted(function (): void {
Registrar::ensureMcpScope();
});
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Generator;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Container\Container;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Contracts\Errable;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use Laravel\Mcp\Support\ValidationMessages;
class CallTool implements Errable, Method
{
use InteractsWithResponses;
/**
* @return JsonRpcResponse|Generator<JsonRpcResponse>
*
* @throws JsonRpcException
*/
public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse
{
if (is_null($request->get('name'))) {
throw new JsonRpcException(
'Missing [name] parameter.',
-32602,
$request->id,
);
}
$tool = $context
->tools()
->first(
fn ($tool): bool => $tool->name() === $request->params['name'],
fn () => throw new JsonRpcException(
"Tool [{$request->params['name']}] not found.",
-32602,
$request->id,
));
try {
// @phpstan-ignore-next-line
$response = Container::getInstance()->call([$tool, 'handle']);
} catch (AuthenticationException|AuthorizationException $authException) {
$response = Response::error($authException->getMessage());
} catch (ValidationException $validationException) {
$response = Response::error(ValidationMessages::from($validationException));
}
return is_iterable($response)
? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($tool))
: $this->toJsonRpcResponse($request, $response, $this->serializable($tool));
}
/**
* @return callable(ResponseFactory): array<string, mixed>
*/
protected function serializable(Tool $tool): callable
{
return fn (ResponseFactory $factory): array => $factory->mergeStructuredContent(
$factory->mergeMeta([
'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(),
'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()),
])
);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Illuminate\Container\Container;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Completions\CompletionResponse;
use Laravel\Mcp\Server\Contracts\Completable;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\Concerns\ResolvesPrompts;
use Laravel\Mcp\Server\Methods\Concerns\ResolvesResources;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class CompletionComplete implements Method
{
use ResolvesPrompts;
use ResolvesResources;
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
if (! $context->hasCapability(Server::CAPABILITY_COMPLETIONS)) {
throw new JsonRpcException(
'Server does not support completions capability.',
-32601,
$request->id,
);
}
$ref = $request->get('ref');
$argument = $request->get('argument');
if (is_null($ref) || is_null($argument)) {
throw new JsonRpcException(
'Missing required parameters: ref and argument',
-32602,
$request->id,
);
}
try {
$primitive = $this->resolvePrimitive($ref, $context);
} catch (InvalidArgumentException $invalidArgumentException) {
throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id);
}
if (! $primitive instanceof Completable) {
$result = CompletionResponse::empty();
return JsonRpcResponse::result($request->id, [
'completion' => $result->toArray(),
]);
}
$argumentName = Arr::get($argument, 'name');
$argumentValue = Arr::get($argument, 'value', '');
if (is_null($argumentName)) {
throw new JsonRpcException(
'Missing argument name.',
-32602,
$request->id,
);
}
$contextArguments = Arr::get($request->get('context'), 'arguments', []);
$result = $this->invokeCompletion($primitive, $argumentName, $argumentValue, $contextArguments);
return JsonRpcResponse::result($request->id, [
'completion' => $result->toArray(),
]);
}
/**
* @param array<string, mixed> $ref
*/
protected function resolvePrimitive(array $ref, ServerContext $context): Prompt|Resource|HasUriTemplate
{
return match (Arr::get($ref, 'type')) {
'ref/prompt' => $this->resolvePrompt(Arr::get($ref, 'name'), $context),
'ref/resource' => $this->resolveResource(Arr::get($ref, 'uri'), $context),
default => throw new InvalidArgumentException('Invalid reference type. Expected ref/prompt or ref/resource.'),
};
}
/**
* @param array<string, mixed> $context
*/
protected function invokeCompletion(
Completable $primitive,
string $argumentName,
string $argumentValue,
array $context
): mixed {
$container = Container::getInstance();
$result = $container->call($primitive->complete(...), [
'argument' => $argumentName,
'value' => $argumentValue,
'context' => $context,
]);
return $result->resolve($argumentValue);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods\Concerns;
use Generator;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Content\Notification;
use Laravel\Mcp\Server\Contracts\Errable;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
trait InteractsWithResponses
{
/**
* @param array<int, Response|ResponseFactory|string>|Response|ResponseFactory|string $response
*
* @throws JsonRpcException
*/
protected function toJsonRpcResponse(JsonRpcRequest $request, Response|ResponseFactory|array|string $response, callable $serializable): JsonRpcResponse
{
$responseFactory = $this->toResponseFactory($response);
$responseFactory->responses()->each(function (Response $response) use ($request): void {
if (! $this instanceof Errable && $response->isError()) {
throw new JsonRpcException(
$response->content()->__toString(), // @phpstan-ignore-line
-32603,
$request->id,
);
}
});
return JsonRpcResponse::result($request->id, $serializable($responseFactory));
}
/**
* @param iterable<Response|ResponseFactory|string> $responses
* @return Generator<JsonRpcResponse>
*/
protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $responses, callable $serializable): Generator
{
/** @var array<int, Response|ResponseFactory|string> $pendingResponses */
$pendingResponses = [];
try {
foreach ($responses as $response) {
if ($response instanceof Response && $response->isNotification()) {
/** @var Notification $content */
$content = $response->content();
yield JsonRpcResponse::notification(
...$content->toArray(),
);
continue;
}
$pendingResponses[] = $response;
}
} catch (AuthenticationException|AuthorizationException $authException) {
yield $this->toJsonRpcResponse(
$request,
Response::error($authException->getMessage()),
$serializable,
);
return;
} catch (ValidationException $validationException) {
yield $this->toJsonRpcResponse(
$request,
Response::error($validationException->getMessage()),
$serializable,
);
return;
}
yield $this->toJsonRpcResponse($request, $pendingResponses, $serializable);
}
protected function isBinary(string $content): bool
{
return str_contains($content, "\0");
}
/**
* @param array<int, Response|ResponseFactory|string>|Response|ResponseFactory|string $response
*/
private function toResponseFactory(Response|ResponseFactory|array|string $response): ResponseFactory
{
$responseFactory = is_array($response) && count($response) === 1
? Arr::first($response)
: $response;
if ($responseFactory instanceof ResponseFactory) {
return $responseFactory;
}
$items = is_array($responseFactory) ? $responseFactory : [$responseFactory];
$responses = collect($items)
->map(function ($item): Response {
if ($item instanceof Response) {
return $item;
}
if (! is_string($item)) {
throw new InvalidArgumentException('Response must be a Response instance or string');
}
return $this->isBinary($item)
? Response::blob($item)
: Response::text($item);
});
return new ResponseFactory($responses->all());
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods\Concerns;
use InvalidArgumentException;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\ServerContext;
trait ResolvesPrompts
{
protected function resolvePrompt(?string $name, ServerContext $context): Prompt
{
if (! $name) {
throw new InvalidArgumentException('Missing [name] parameter.');
}
return $context->prompts()->first(
fn ($prompt): bool => $prompt->name() === $name,
fn () => throw new InvalidArgumentException("Prompt [{$name}] not found.")
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods\Concerns;
use InvalidArgumentException;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\ServerContext;
trait ResolvesResources
{
protected function resolveResource(?string $uri, ServerContext $context): Resource
{
if (! $uri) {
throw new InvalidArgumentException('Missing [uri] parameter.');
}
$resource = $context->resources()->first(fn ($resource): bool => $resource->uri() === $uri)
?? $context->resourceTemplates()->first(fn ($template): bool => (string) $template->uriTemplate() === $uri
|| $template->uriTemplate()->match($uri) !== null);
if (! $resource) {
throw new InvalidArgumentException("Resource [{$uri}] not found.");
}
return $resource;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Generator;
use Illuminate\Container\Container;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses;
use Laravel\Mcp\Server\Methods\Concerns\ResolvesPrompts;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use Laravel\Mcp\Support\ValidationMessages;
class GetPrompt implements Method
{
use InteractsWithResponses;
use ResolvesPrompts;
/**
* @return Generator<JsonRpcResponse>|JsonRpcResponse
*/
public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse
{
try {
$prompt = $this->resolvePrompt($request->get('name'), $context);
} catch (InvalidArgumentException $invalidArgumentException) {
throw new JsonRpcException($invalidArgumentException->getMessage(), -32602, $request->id);
}
try {
// @phpstan-ignore-next-line
$response = Container::getInstance()->call([$prompt, 'handle']);
} catch (ValidationException $validationException) {
$response = Response::error('Invalid params: '.ValidationMessages::from($validationException));
}
return is_iterable($response)
? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($prompt))
: $this->toJsonRpcResponse($request, $response, $this->serializable($prompt));
}
/**
* @return callable(ResponseFactory): array<string, mixed>
*/
protected function serializable(Prompt $prompt): callable
{
return fn (ResponseFactory $factory): array => $factory->mergeMeta([
'description' => $prompt->description(),
'messages' => $factory->responses()->map(fn (Response $response): array => [
'role' => $response->role()->value,
'content' => $response->content()->toPrompt($prompt),
])->all(),
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class Initialize implements Method
{
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
$requestedVersion = $request->params['protocolVersion'] ?? null;
if (! is_null($requestedVersion) && ! in_array($requestedVersion, $context->supportedProtocolVersions, true)) {
throw new JsonRpcException(
message: 'Unsupported protocol version',
code: -32602,
requestId: $request->id,
data: [
'supported' => $context->supportedProtocolVersions,
'requested' => $requestedVersion,
]
);
}
$protocolVersion = $requestedVersion ?? $context->supportedProtocolVersions[0];
$initResult = [
'protocolVersion' => $protocolVersion,
'capabilities' => $context->serverCapabilities,
'serverInfo' => [
'name' => $context->serverName,
'version' => $context->serverVersion,
],
'instructions' => $context->instructions,
];
if (in_array($protocolVersion, ['2024-11-05', '2025-03-26'], true)) {
unset($initResult['instructions']);
}
return JsonRpcResponse::result($request->id, $initResult);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Pagination\CursorPaginator;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class ListPrompts implements Method
{
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
$paginator = new CursorPaginator(
items: $context->prompts(),
perPage: $context->perPage($request->get('per_page')),
cursor: $request->cursor(),
);
return JsonRpcResponse::result($request->id, $paginator->paginate('prompts'));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Pagination\CursorPaginator;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class ListResourceTemplates implements Method
{
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
$paginator = new CursorPaginator(
items: $context->resourceTemplates(),
perPage: $context->perPage($request->get('per_page')),
cursor: $request->cursor(),
);
return JsonRpcResponse::result($request->id, $paginator->paginate('resourceTemplates'));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Pagination\CursorPaginator;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class ListResources implements Method
{
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
$paginator = new CursorPaginator(
items: $context->resources(),
perPage: $context->perPage($request->get('per_page')),
cursor: $request->cursor(),
);
return JsonRpcResponse::result($request->id, $paginator->paginate('resources'));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Pagination\CursorPaginator;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class ListTools implements Method
{
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
$paginator = new CursorPaginator(
items: $context->tools(),
perPage: $context->perPage($request->get('per_page')),
cursor: $request->cursor(),
);
return JsonRpcResponse::result($request->id, $paginator->paginate('tools'));
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class Ping implements Method
{
public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse
{
return JsonRpcResponse::result($request->id, []);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Methods;
use Generator;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\AppResource;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
use Laravel\Mcp\Server\Contracts\Method;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses;
use Laravel\Mcp\Server\Methods\Concerns\ResolvesResources;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use Laravel\Mcp\Support\ValidationMessages;
class ReadResource implements Method
{
use InteractsWithResponses;
use ResolvesResources;
/**
* @return Generator<JsonRpcResponse>|JsonRpcResponse
*
* @throws BindingResolutionException
*/
public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse
{
$uri = $request->get('uri');
try {
$resource = $this->resolveResource($uri, $context);
} catch (InvalidArgumentException $invalidArgumentException) {
throw new JsonRpcException($invalidArgumentException->getMessage(), -32002, $request->id);
}
try {
$response = $this->invokeResource($resource, $uri);
} catch (ValidationException $validationException) {
$response = Response::error('Invalid params: '.ValidationMessages::from($validationException));
}
return is_iterable($response)
? $this->toJsonRpcStreamedResponse($request, $response, $this->serializable($resource, $uri))
: $this->toJsonRpcResponse($request, $response, $this->serializable($resource, $uri));
}
/**
* @throws BindingResolutionException
* @throws ValidationException
*/
protected function invokeResource(Resource $resource, string $uri): mixed
{
$container = Container::getInstance();
$request = $container->make(Request::class);
$request->setUri($uri);
if ($resource instanceof HasUriTemplate) {
$variables = $resource->uriTemplate()->match($uri) ?? [];
$request->merge($variables);
}
$container->instance(Request::class, $request);
if ($resource instanceof AppResource) {
$container->instance('mcp.library_scripts', $resource->libraryScripts());
}
try {
// @phpstan-ignore-next-line
return $container->call([$resource, 'handle']);
} finally {
$container->forgetInstance(Request::class);
$container->forgetInstance('mcp.library_scripts');
}
}
protected function serializable(Resource $resource, string $uri): callable
{
$appMeta = $resource instanceof AppResource ? $resource->resolvedAppMeta() : null;
return fn (ResponseFactory $factory): array => $factory->mergeMeta([
'contents' => $factory->responses()->map(function (Response $response) use ($resource, $uri, $appMeta): array {
$content = [
...$response->content()->toResource($resource),
'uri' => $uri,
];
if ($appMeta !== null && $appMeta !== []) {
$content['_meta'] = array_merge($content['_meta'] ?? [], [
'ui' => $appMeta,
]);
}
return $content;
})->all(),
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AddWwwAuthenticateHeader
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (\Illuminate\Http\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response->getStatusCode() !== 401) {
return $response;
}
$isOauth = app('router')->has('mcp.oauth.protected-resource.nested');
if ($isOauth) {
$response->header(
'WWW-Authenticate',
'Bearer realm="mcp", resource_metadata="'.route('mcp.oauth.protected-resource.nested', ['path' => $request->path()]).'"'
);
return $response;
}
// Sanctum, can't share discover URL
$response->header(
'WWW-Authenticate',
'Bearer realm="mcp", error="invalid_token"'
);
return $response;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ReorderJsonAccept
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$accept = $request->header('Accept');
if (is_string($accept) && str_contains($accept, ',')) {
$accept = array_map(trim(...), explode(',', $accept));
}
if (! is_array($accept)) {
return $next($request);
}
usort($accept, fn ($a, $b): int => str_contains((string) $b, 'application/json') <=> str_contains((string) $a, 'application/json'));
$request->headers->set('Accept', implode(', ', $accept));
return $next($request);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Pagination;
use Illuminate\Support\Collection;
use Throwable;
class CursorPaginator
{
/**
* @param Collection<int, mixed> $items
*/
public function __construct(protected Collection $items, protected int $perPage = 10, protected ?string $cursor = null)
{
$this->items = $items->values();
}
/**
* @return array<string, mixed>
*/
public function paginate(string $key = 'items'): array
{
$startOffset = $this->getStartOffsetFromCursor();
$paginatedItems = $this->items->slice($startOffset, $this->perPage);
$hasMorePages = $this->items->count() > ($startOffset + $this->perPage);
$result = [$key => $paginatedItems->values()->toArray()];
if ($hasMorePages) {
$result['nextCursor'] = $this->createCursor($startOffset + $this->perPage);
}
return $result;
}
protected function getStartOffsetFromCursor(): int
{
if (! is_string($this->cursor)) {
return 0;
}
try {
$decodedCursor = base64_decode($this->cursor, true);
if ($decodedCursor === false) {
return 0;
}
$cursorData = json_decode($decodedCursor, true);
if (! is_array($cursorData)) {
return 0;
}
return (int) ($cursorData['offset'] ?? 0);
} catch (Throwable) {
//
}
return 0;
}
protected function createCursor(int $offset): string
{
$cursorData = ['offset' => $offset];
return base64_encode((string) json_encode($cursorData));
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Illuminate\Container\Container;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Concerns\HasMeta;
use Laravel\Mcp\Server\Concerns\ReadsAttributes;
/**
* @implements Arrayable<string, mixed>
*/
abstract class Primitive implements Arrayable
{
use HasMeta;
use ReadsAttributes;
protected string $name = '';
protected string $title = '';
protected string $description = '';
public function name(): string
{
$attribute = $this->resolveAttribute(Name::class);
return $attribute !== null
? $attribute->value
: ($this->name !== '' ? $this->name : Str::kebab(class_basename($this)));
}
public function title(): string
{
$attribute = $this->resolveAttribute(Title::class);
return $attribute !== null
? $attribute->value
: ($this->title !== '' ? $this->title : Str::headline(class_basename($this)));
}
public function description(): string
{
$attribute = $this->resolveAttribute(Description::class);
return $attribute !== null
? $attribute->value
: ($this->description !== '' ? $this->description : Str::headline(class_basename($this)));
}
/**
* @return array<string, mixed>|null
*/
public function meta(): ?array
{
return $this->meta;
}
public function eligibleForRegistration(): bool
{
if (method_exists($this, 'shouldRegister')) {
return Container::getInstance()->call([$this, 'shouldRegister']);
}
return true;
}
/**
* @return array<string, mixed>
*/
abstract public function toMethodCall(): array;
/**
* @return array<string, mixed>
*/
abstract public function toArray(): array;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Laravel\Mcp\Server\Prompts\Argument;
use Laravel\Mcp\Server\Prompts\Arguments;
abstract class Prompt extends Primitive
{
/**
* @return array<int, Argument>
*/
public function arguments(): array
{
return [
//
];
}
/**
* @return array<string, mixed>
*/
public function toMethodCall(): array
{
return ['name' => $this->name()];
}
/**
* @return array{name: string, title: string, description: string, arguments: array<int, array{name: string, description: string, required: bool, _meta?: array<string, mixed>}>}
*/
public function toArray(): array
{
// @phpstan-ignore return.type
return $this->mergeMeta([
'name' => $this->name(),
'title' => $this->title(),
'description' => $this->description(),
'arguments' => array_map(
fn (Argument $argument): array => $argument->toArray(),
$this->arguments(),
),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Prompts;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, mixed>
*/
class Argument implements Arrayable
{
public function __construct(
public string $name,
public string $description,
public bool $required = false,
) {
//
}
/**
* @return array{name: string, description: string, required: bool}
*/
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'required' => $this->required,
];
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Illuminate\Container\Container;
use Illuminate\Http\Response;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
use Illuminate\Support\Str;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Contracts\Transport;
use Laravel\Mcp\Server\Http\Controllers\OAuthRegisterController;
use Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader;
use Laravel\Mcp\Server\Middleware\ReorderJsonAccept;
use Laravel\Mcp\Server\Transport\HttpTransport;
use Laravel\Mcp\Server\Transport\StdioTransport;
use Laravel\Passport\Passport;
class Registrar
{
/** @var array<string, callable> */
protected array $localServers = [];
/** @var array<string, Route> */
protected array $httpServers = [];
/**
* @param class-string<Server> $serverClass
*/
public function web(string $route, string $serverClass): Route
{
// https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
Router::get($route, fn (): Response => response('', 405)->header('Allow', 'POST'));
Router::delete($route, fn (): Response => response('', 405)->header('Allow', 'POST'));
$route = Router::post($route, static fn (): mixed => static::startServer(
$serverClass,
static fn (): HttpTransport => new HttpTransport(
$request = request(),
// @phpstan-ignore-next-line
(string) $request->header('MCP-Session-Id')
),
))->middleware([
ReorderJsonAccept::class,
AddWwwAuthenticateHeader::class,
]);
assert($route instanceof Route);
$this->httpServers[$route->uri()] = $route;
return $route;
}
/**
* @param class-string<Server> $serverClass
*/
public function local(string $handle, string $serverClass): void
{
$this->localServers[$handle] = fn (): mixed => static::startServer($serverClass, fn (): StdioTransport => new StdioTransport(
Str::uuid()->toString(),
));
}
public function getLocalServer(string $handle): ?callable
{
return $this->localServers[$handle] ?? null;
}
public function getWebServer(string $route): ?Route
{
return $this->httpServers[$route] ?? null;
}
/**
* @return array<string, callable|Route>
*/
public function servers(): array
{
return array_merge(
$this->localServers,
$this->httpServers,
);
}
public function oauthRoutes(string $oauthPrefix = 'oauth'): void
{
static::ensureMcpScope();
$hasExactProtectedResourceRoute = $this->hasGetRoute('.well-known/oauth-protected-resource');
$hasExactAuthorizationServerRoute = $this->hasGetRoute('.well-known/oauth-authorization-server');
if (! $hasExactProtectedResourceRoute) {
Router::get('/.well-known/oauth-protected-resource', static fn () => response()->json(static::protectedResourceMetadata('')))
->name('mcp.oauth.protected-resource');
}
if (! $hasExactAuthorizationServerRoute) {
Router::get('/.well-known/oauth-authorization-server', static fn () => response()->json(static::authorizationServerMetadata($oauthPrefix)))
->name('mcp.oauth.authorization-server');
}
Router::get('/.well-known/oauth-protected-resource/{path}', static fn (string $path) => response()->json(static::protectedResourceMetadata($path)))
->where('path', '.*')
->name('mcp.oauth.protected-resource.nested');
Router::get('/.well-known/oauth-authorization-server/{path}', static fn (string $path) => response()->json(static::authorizationServerMetadata($oauthPrefix)))
->where('path', '.*')
->name('mcp.oauth.authorization-server.nested');
Router::post($oauthPrefix.'/register', OAuthRegisterController::class);
}
/**
* @return array<string, array<int, string>|string>
*/
protected static function authorizationServerMetadata(string $oauthPrefix): array
{
return [
'issuer' => config('mcp.authorization_server') ?? url('/'),
'authorization_endpoint' => route('passport.authorizations.authorize'),
'token_endpoint' => route('passport.token'),
'registration_endpoint' => url($oauthPrefix.'/register'),
'response_types_supported' => ['code'],
'code_challenge_methods_supported' => ['S256'],
'scopes_supported' => ['mcp:use'],
'grant_types_supported' => ['authorization_code', 'refresh_token'],
];
}
/**
* @return array<string, array<int, string>|string>
*/
protected static function protectedResourceMetadata(string $path): array
{
return [
'resource' => url('/'.$path),
'authorization_servers' => [config('mcp.authorization_server') ?? url('/')],
'scopes_supported' => ['mcp:use'],
];
}
protected function hasGetRoute(string $uri): bool
{
foreach (Router::getRoutes()->getRoutes() as $route) {
if ($route->uri() === $uri && in_array('GET', $route->methods(), true)) {
return true;
}
}
return false;
}
/**
* @return array<string, string>
*/
public static function ensureMcpScope(): array
{
if (class_exists(Passport::class) === false) {
return [];
}
$current = Passport::$scopes ?? [];
if (! array_key_exists('mcp:use', $current)) {
$current['mcp:use'] = 'Use MCP server';
Passport::tokensCan($current);
}
return $current;
}
/**
* @param class-string<Server> $serverClass
* @param callable(): Transport $transportFactory
*/
protected static function startServer(string $serverClass, callable $transportFactory): mixed
{
$transport = $transportFactory();
$server = Container::getInstance()->make($serverClass, [
'transport' => $transport,
]);
$server->start();
return $transport->run();
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Annotations\Annotation;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Concerns\HasAnnotations;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
abstract class Resource extends Primitive
{
use HasAnnotations;
protected string $uri = '';
protected string $mimeType = '';
protected string $defaultUriScheme = 'file';
public function uri(): string
{
if ($this instanceof HasUriTemplate) {
return (string) $this->uriTemplate();
}
$attribute = $this->resolveAttribute(Uri::class);
return $attribute !== null
? $attribute->value
: ($this->uri !== '' ? $this->uri : $this->defaultUriScheme.'://resources/'.Str::kebab(class_basename($this)));
}
public function mimeType(): string
{
$attribute = $this->resolveAttribute(MimeType::class);
return $attribute !== null
? $attribute->value
: ($this->mimeType !== '' ? $this->mimeType : 'text/plain');
}
/**
* @return array<string, mixed>
*/
public function toMethodCall(): array
{
return ['uri' => $this->uri()];
}
/**
* @return array{
* name: string,
* title: string,
* description: string,
* uri?: string,
* uriTemplate?: string,
* mimeType: string,
* _meta?: array<string, mixed>
* }
*/
public function toArray(): array
{
$annotations = $this->annotations();
$data = [
'name' => $this->name(),
'title' => $this->title(),
'description' => $this->description(),
'mimeType' => $this->mimeType(),
];
if ($annotations !== []) {
$data['annotations'] = $annotations;
}
if ($this instanceof HasUriTemplate) {
$data['uriTemplate'] = (string) $this->uriTemplate();
} else {
$data['uri'] = $this->uri();
}
// @phpstan-ignore return.type
return $this->mergeMeta($data);
}
/**
* @return array<int, class-string>
*/
protected function allowedAnnotations(): array
{
return [
Annotation::class,
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Illuminate\Container\Container;
use Illuminate\Support\Collection;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
class ServerContext
{
/**
* @param array<int, string> $supportedProtocolVersions
* @param array<string, mixed> $serverCapabilities
* @param array<int, Tool|string> $tools
* @param array<int, Resource|string> $resources
* @param array<int, Prompt|string> $prompts
*/
public function __construct(
public array $supportedProtocolVersions,
public array $serverCapabilities,
public string $serverName,
public string $serverVersion,
public string $instructions,
public int $maxPaginationLength,
public int $defaultPaginationLength,
protected array $tools,
protected array $resources,
protected array $prompts,
) {
//
}
/**
* @return Collection<int, Tool>
*/
public function tools(): Collection
{
/** @var Collection<int,Tool> $tools */
$tools = collect($this->tools);
return $this->resolvePrimitives($tools);
}
/**
* @return Collection<int, Resource>
*/
public function resources(): Collection
{
/** @var Collection<int,Resource> $resourceTemplates */
$resourceTemplates = collect($this->resources)
->filter(fn (Resource|string $resource): bool => ! $this->isResourceTemplate($resource));
return $this->resolvePrimitives($resourceTemplates);
}
/**
* @return Collection<int, HasUriTemplate&Resource>
*/
public function resourceTemplates(): Collection
{
/** @var Collection<int,HasUriTemplate&Resource> $resourceTemplates */
$resourceTemplates = collect($this->resources)
->filter(fn (Resource|string $resource): bool => $this->isResourceTemplate($resource));
return $this->resolvePrimitives($resourceTemplates);
}
/**
* @return Collection<int, Prompt>
*/
public function prompts(): Collection
{
/** @var Collection<int,Prompt> $prompts */
$prompts = collect($this->prompts);
return $this->resolvePrimitives($prompts);
}
public function perPage(?int $requestedPerPage = null): int
{
return min($requestedPerPage ?? $this->defaultPaginationLength, $this->maxPaginationLength);
}
public function hasCapability(string $capability): bool
{
return array_key_exists($capability, $this->serverCapabilities);
}
/**
* @template T of Primitive
*
* @param Collection<int, T|string> $primitive
* @return Collection<int, T>
*/
private function resolvePrimitives(Collection $primitive): Collection
{
return $primitive->map(fn (Primitive|string $primitiveClass) => is_string($primitiveClass)
? Container::getInstance()->make($primitiveClass)
: $primitiveClass)
->filter(fn (Primitive $primitive): bool => $primitive->eligibleForRegistration());
}
private function isResourceTemplate(Resource|string $resource): bool
{
return $resource instanceof HasUriTemplate || (is_string($resource) && is_subclass_of($resource, HasUriTemplate::class));
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Testing;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use InvalidArgumentException;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
use Laravel\Mcp\Server\Primitive;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Transport\FakeTransporter;
use Laravel\Mcp\Server\Transport\JsonRpcRequest;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
class PendingTestResponse
{
/**
* @param class-string<Server> $serverClass
*/
public function __construct(
protected Container $app,
protected string $serverClass
) {
//
}
/**
* @param class-string<Tool>|Tool $tool
* @param array<string, mixed> $arguments
*/
public function tool(Tool|string $tool, array $arguments = []): TestResponse
{
return $this->run('tools/call', $tool, $arguments);
}
/**
* @param class-string<Prompt>|Prompt $prompt
* @param array<string, mixed> $arguments
*/
public function prompt(Prompt|string $prompt, array $arguments = []): TestResponse
{
return $this->run('prompts/get', $prompt, $arguments);
}
/**
* @param class-string<Resource>|Resource $resource
* @param array<string, mixed> $arguments
*/
public function resource(Resource|string $resource, array $arguments = []): TestResponse
{
return $this->run('resources/read', $resource, $arguments);
}
/**
* @param class-string<Primitive>|Primitive $primitive
* @param array<string, mixed> $currentArgs
*/
public function completion(
Primitive|string $primitive,
string $argumentName,
string $argumentValue = '',
array $currentArgs = []
): TestResponse {
$primitive = $this->resolvePrimitive($primitive);
$server = $this->initializeServer();
$request = new JsonRpcRequest(
uniqid(),
'completion/complete',
[
'ref' => $this->buildCompletionRef($primitive),
'argument' => [
'name' => $argumentName,
'value' => $argumentValue,
],
'context' => [
'arguments' => $currentArgs,
],
],
);
$response = $this->executeRequest($server, $request);
return new TestResponse($primitive, $response);
}
/**
* @return array<string, mixed>
*/
protected function buildCompletionRef(Primitive $primitive): array
{
return match (true) {
$primitive instanceof Prompt => [
'type' => 'ref/prompt',
'name' => $primitive->name(),
],
$primitive instanceof Resource => [
'type' => 'ref/resource',
'uri' => $primitive->uri(),
],
default => throw new InvalidArgumentException('Unsupported primitive type for completion.'),
};
}
protected function resolvePrimitive(Primitive|string $primitive): Primitive
{
return is_string($primitive)
? Container::getInstance()->make($primitive)
: $primitive;
}
protected function initializeServer(): Server
{
$server = Container::getInstance()->make(
$this->serverClass,
['transport' => new FakeTransporter]
);
$server->start();
return $server;
}
protected function executeRequest(Server $server, JsonRpcRequest $request): mixed
{
try {
return (fn (): iterable|JsonRpcResponse => $this->runMethodHandle($request, $this->createContext()))->call($server);
} catch (JsonRpcException $jsonRpcException) {
return $jsonRpcException->toJsonRpcResponse();
}
}
public function actingAs(Authenticatable $user, ?string $guard = null): static
{
if (property_exists($user, 'wasRecentlyCreated')) {
$user->wasRecentlyCreated = false;
}
$this->app['auth']->guard($guard)->setUser($user);
$this->app['auth']->shouldUse($guard);
return $this;
}
/**
* @param class-string<Primitive>|Primitive $primitive
* @param array<string, mixed> $arguments
*
* @throws JsonRpcException
*/
protected function run(string $method, Primitive|string $primitive, array $arguments = []): TestResponse
{
$primitive = $this->resolvePrimitive($primitive);
$server = $this->initializeServer();
$request = new JsonRpcRequest(
uniqid(),
$method,
[
...$primitive->toMethodCall(),
'arguments' => $arguments,
],
);
$response = $this->executeRequest($server, $request);
return new TestResponse($primitive, $response);
}
}

View File

@@ -0,0 +1,375 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Testing;
use Closure;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Traits\Conditionable;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Mcp\Server\Primitive;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Transport\JsonRpcResponse;
use PHPUnit\Framework\Assert;
use RuntimeException;
class TestResponse
{
use Conditionable;
use Macroable;
protected JsonRpcResponse $response;
/**
* @var array<int, JsonRpcResponse>
*/
protected array $notifications = [];
/**
* @param iterable<int, JsonRpcResponse>|JsonRpcResponse $response
*/
public function __construct(
protected Primitive $primitive,
iterable|JsonRpcResponse $response,
) {
$responses = is_iterable($response)
? iterator_to_array($response)
: [$response];
foreach ($responses as $response) {
$content = $response->toArray();
if (isset($content['id'])) {
$this->response = $response;
} else {
$this->notifications[] = $response;
}
}
}
/**
* @param array<string>|string $text
*/
public function assertSee(array|string $text): static
{
$seeable = collect([
...$this->content(),
...$this->errors(),
])->filter()->unique()->values()->all();
foreach (is_array($text) ? $text : [$text] as $segment) {
foreach ($seeable as $message) {
if (str_contains($message, $segment)) {
continue 2;
}
}
// @phpstan-ignore-next-line
Assert::assertTrue(false, "The expected text [{$segment}] was not found in the response content.");
}
// @phpstan-ignore-next-line
Assert::assertTrue(true);
return $this;
}
/**
* @param array<string>|string $text
*/
public function assertDontSee(array|string $text): static
{
$seeable = collect([
...$this->content(),
...$this->errors(),
])->filter()->unique()->values()->all();
foreach (is_array($text) ? $text : [$text] as $segment) {
foreach ($seeable as $message) {
if (str_contains($message, $segment)) {
// @phpstan-ignore-next-line
Assert::assertTrue(false, "The unexpected text [{$segment}] was found in the response content.");
return $this;
}
}
}
// @phpstan-ignore-next-line
Assert::assertTrue(true);
return $this;
}
/**
* @param array<string, mixed>|Closure(AssertableJson): bool $structuredContent
*/
public function assertStructuredContent(Closure|array $structuredContent): static
{
if ($structuredContent instanceof Closure) {
$assertableJson = AssertableJson::fromArray($this->response->toArray()['result']['structuredContent'] ?? null);
$structuredContent($assertableJson);
$assertableJson->interacted();
return $this;
}
Assert::assertSame(
$structuredContent,
$this->response->toArray()['result']['structuredContent'] ?? null,
'The expected structured content does not match the actual structured content.'
);
return $this;
}
public function assertNotificationCount(int $count): static
{
Assert::assertCount($count, $this->notifications, "The expected number of notifications [{$count}] does not match the actual count.");
return $this;
}
/**
* @param array<string, mixed>|null $params
*/
public function assertSentNotification(string $method, ?array $params = null): static
{
foreach ($this->notifications as $notification) {
$content = $notification->toArray();
if ($content['method'] === $method && (is_array($params) === false || $content['params'] === $params)) {
Assert::assertTrue(true); // @phpstan-ignore-line
return $this;
}
}
Assert::fail("The expected notification [{$method}], but it was not found.");
}
public function assertName(string $name): static
{
Assert::assertEquals(
$name,
$this->primitive->name(),
"The expected name [{$name}] does not match the actual name [{$this->primitive->name()}].",
);
return $this;
}
public function assertTitle(string $title): static
{
Assert::assertEquals(
$title,
$this->primitive->title(),
"The expected title [{$title}] does not match the actual title [{$this->primitive->title()}].",
);
return $this;
}
public function assertDescription(string $description): static
{
Assert::assertEquals(
$description,
$this->primitive->description(),
"The expected description [{$description}] does not match the actual description [{$this->primitive->description()}].",
);
return $this;
}
public function assertOk(): static
{
return $this->assertHasNoErrors();
}
public function assertHasNoErrors(): static
{
Assert::assertEmpty($this->errors());
return $this;
}
/**
* @param array<string> $messages
*/
public function assertHasErrors(array $messages = []): static
{
$errors = $this->errors();
Assert::assertNotEmpty($errors, 'The response has no errors.');
foreach ($messages as $message) {
foreach ($errors as $error) {
if (str_contains($error, $message)) {
continue 2;
}
}
Assert::fail("The expected error message [{$message}] was not found in the response.");
}
return $this;
}
public function assertAuthenticated(?string $guard = null): static
{
Assert::assertTrue($this->isAuthenticated($guard), 'The user is not authenticated');
return $this;
}
public function assertGuest(?string $guard = null): static
{
Assert::assertFalse($this->isAuthenticated($guard), 'The user is authenticated');
return $this;
}
public function assertAuthenticatedAs(Authenticatable $user, ?string $guard = null): static
{
$expected = Container::getInstance()->make('auth')->guard($guard)->user();
Assert::assertNotNull($expected, 'The current user is not authenticated.');
Assert::assertInstanceOf(
$expected::class, $user,
'The currently authenticated user is not who was expected'
);
Assert::assertSame(
$expected->getAuthIdentifier(), $user->getAuthIdentifier(),
'The currently authenticated user is not who was expected'
);
return $this;
}
protected function isAuthenticated(?string $guard = null): bool
{
return Container::getInstance()->make('auth')->guard($guard)->check();
}
/**
* @param array<int, string> $expectedValues
*/
public function assertHasCompletions(array $expectedValues = []): static
{
$actualValues = $this->completionValues();
Assert::assertNotNull(
$this->response->toArray()['result']['completion'] ?? null,
'No completion data found in response.'
);
foreach ($expectedValues as $expected) {
Assert::assertContains(
$expected,
$actualValues,
"Expected completion value [{$expected}] not found."
);
}
return $this;
}
/**
* @param array<int, string> $values
*/
public function assertCompletionValues(array $values): static
{
Assert::assertEquals(
$values,
$this->completionValues(),
'Completion values do not match expected values.'
);
return $this;
}
public function assertCompletionCount(int $count): static
{
$values = $this->completionValues();
Assert::assertCount(
$count,
$values,
"Expected {$count} completions, but got ".count($values)
);
return $this;
}
public function dd(): void
{
dd($this->response->toArray());
}
public function dump(): void
{
dump($this->response->toArray());
}
public function ddErrors(): void
{
dd($this->errors());
}
/**
* @return array<int, string>
*/
protected function content(): array
{
return (match (true) {
// @phpstan-ignore-next-line
$this->primitive instanceof Tool => collect($this->response->toArray()['result']['content'] ?? [])
->map(fn (array $message): string => $message['text'] ?? $message['data'] ?? ''),
// @phpstan-ignore-next-line
$this->primitive instanceof Prompt => collect($this->response->toArray()['result']['messages'] ?? [])
->map(fn (array $message): array => $message['content'])
->map(fn (array $content): string => $content['text'] ?? $content['data'] ?? ''),
// @phpstan-ignore-next-line
$this->primitive instanceof Resource => collect($this->response->toArray()['result']['contents'] ?? [])
->map(fn (array $item): string => $item['text'] ?? $item['blob'] ?? ''),
default => throw new RuntimeException('This primitive type is not supported.'),
})->filter()->unique()->values()->all();
}
/**
* @return array<int, string>
*/
protected function errors(): array
{
$response = $this->response->toArray();
if (data_get($response, 'result.isError', false)) {
return $this->content();
}
if (array_key_exists('error', $response)) {
return [$response['error']['message']];
}
return [];
}
/**
* @return array<int, string>
*/
protected function completionValues(): array
{
$response = $this->response->toArray();
return $response['result']['completion']['values'] ?? [];
}
}

109
vendor/laravel/mcp/src/Server/Tool.php vendored Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server;
use Illuminate\Container\Container;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\JsonSchema as JsonSchemaFactory;
use Laravel\Mcp\Server\Attributes\RendersApp;
use Laravel\Mcp\Server\Concerns\HasAnnotations;
use Laravel\Mcp\Server\Tools\Annotations\ToolAnnotation;
use Laravel\Mcp\Server\Ui\Enums\Visibility;
abstract class Tool extends Primitive
{
use HasAnnotations;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [];
}
/**
* Define the output schema for this tool's results.
*
* @return array<string, mixed>
*/
public function outputSchema(JsonSchema $schema): array
{
return [];
}
/**
* @return array<string, mixed>
*/
public function toMethodCall(): array
{
return ['name' => $this->name()];
}
/**
* Get the tool's array representation.
*
* @return array{
* name: string,
* title?: string|null,
* description?: string|null,
* inputSchema?: array<string, mixed>,
* outputSchema?: array<string, mixed>,
* annotations?: array<string, mixed>|object,
* _meta?: array<string, mixed>
* }
*/
public function toArray(): array
{
$annotations = $this->annotations();
$schema = JsonSchemaFactory::object(
$this->schema(...),
)->toArray();
$outputSchema = JsonSchemaFactory::object(
$this->outputSchema(...),
)->toArray();
$schema['properties'] ??= (object) [];
$result = [
'name' => $this->name(),
'title' => $this->title(),
'description' => $this->description(),
'inputSchema' => $schema,
'annotations' => $annotations === [] ? (object) [] : $annotations,
];
if (isset($outputSchema['properties'])) {
$result['outputSchema'] = $outputSchema;
}
$rendersApp = $this->resolveAttribute(RendersApp::class);
if ($rendersApp !== null) {
/** @var AppResource $appResource */
$appResource = Container::getInstance()->make($rendersApp->resource);
$this->setMeta('ui', [
'resourceUri' => $appResource->uri(),
'visibility' => array_map(fn (Visibility $visiblity) => $visiblity->value, $rendersApp->visibility),
]);
}
// @phpstan-ignore return.type
return $this->mergeMeta($result);
}
/**
* @return array<int, class-string>
*/
protected function allowedAnnotations(): array
{
return [
ToolAnnotation::class,
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Tools\Annotations;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class IsDestructive extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
//
}
public function key(): string
{
return 'destructiveHint';
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Tools\Annotations;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class IsIdempotent extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
//
}
public function key(): string
{
return 'idempotentHint';
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Tools\Annotations;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class IsOpenWorld extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
//
}
public function key(): string
{
return 'openWorldHint';
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Tools\Annotations;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class IsReadOnly extends ToolAnnotation
{
public function __construct(public bool $value = true)
{
//
}
public function key(): string
{
return 'readOnlyHint';
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Tools\Annotations;
use Laravel\Mcp\Server\Contracts\Annotation;
abstract class ToolAnnotation implements Annotation
{
//
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Transport;
use Closure;
use Illuminate\Http\Response;
use Laravel\Mcp\Server\Contracts\Transport;
use LogicException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FakeTransporter implements Transport
{
public function onReceive(Closure $handler): void
{
//
}
public function send(string $message, ?string $sessionId = null): void
{
//
}
public function run(): Response|StreamedResponse
{
throw new LogicException('Not implemented.');
}
public function sessionId(): ?string
{
return uniqid();
}
public function stream(Closure $stream): void
{
//
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Transport;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Mcp\Server\Contracts\Transport;
use Symfony\Component\HttpFoundation\StreamedResponse;
class HttpTransport implements Transport
{
/**
* @param (Closure(string): void)|null $handler
*/
public function __construct(
protected Request $request,
protected string $sessionId,
protected ?Closure $handler = null,
protected ?string $reply = null,
protected ?string $replySessionId = null,
protected ?Closure $stream = null,
) {
//
}
public function onReceive(Closure $handler): void
{
$this->handler = $handler;
}
public function send(string $message, ?string $sessionId = null): void
{
if ($this->stream instanceof Closure) {
$this->sendStreamMessage($message);
}
$this->reply = $message;
$this->replySessionId = $sessionId;
}
public function run(): Response|StreamedResponse
{
if (is_callable($this->handler)) {
($this->handler)($this->request->getContent());
}
if ($this->stream instanceof Closure) {
$stream = $this->stream;
return response()->stream(function () use ($stream): void {
$result = $stream();
if (! is_iterable($result)) {
return;
}
foreach ($result as $message) {
if (connection_aborted() !== 0) {
return;
}
$this->sendStreamMessage((string) $message);
}
}, 200, $this->getHeaders());
}
// Must be 202 - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
$statusCode = $this->reply === null ? 202 : 200;
$response = response($this->reply, $statusCode, $this->getHeaders());
assert($response instanceof Response);
return $response;
}
public function sessionId(): ?string
{
return $this->sessionId;
}
/**
* Register a streaming callback.
*
* The callback may echo SSE-formatted output directly or return an iterable of message payloads.
*
* @param Closure(): (iterable<string>|void) $stream
*/
public function stream(Closure $stream): void
{
$this->stream = $stream;
}
protected function sendStreamMessage(string $message): void
{
echo 'data: '.$message."\n\n";
if (ob_get_level() !== 0) {
ob_flush();
}
flush();
}
/**
* @return array<string, string>
*/
protected function getHeaders(): array
{
$headers = [
'Content-Type' => $this->stream instanceof Closure ? 'text/event-stream' : 'application/json',
];
if ($this->replySessionId !== null) {
$headers['MCP-Session-Id'] = $this->replySessionId;
}
if ($this->stream instanceof Closure) {
$headers['X-Accel-Buffering'] = 'no';
}
return $headers;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Transport;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
class JsonRpcNotification
{
/**
* @param array<string, mixed> $params
*/
public function __construct(
public string $method,
public array $params,
) {
//
}
/**
* @param array{jsonrpc?: mixed, method?: mixed, params?: array<string, mixed>} $jsonRequest
*
* @throws JsonRpcException
*/
public static function from(array $jsonRequest): static
{
if (! isset($jsonRequest['jsonrpc']) || $jsonRequest['jsonrpc'] !== '2.0') {
throw new JsonRpcException('Invalid Request: Invalid JSON-RPC version. Must be "2.0".', -32600);
}
if (! isset($jsonRequest['method']) || ! is_string($jsonRequest['method'])) {
throw new JsonRpcException('Invalid Request: Invalid or missing "method". Must be a string.', -32600);
}
return new static(
method: $jsonRequest['method'],
params: $jsonRequest['params'] ?? []
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Transport;
use Laravel\Mcp\Request;
use Laravel\Mcp\Server\Exceptions\JsonRpcException;
class JsonRpcRequest
{
/**
* @param array<string, mixed> $params
*/
public function __construct(
public int|string $id,
public string $method,
public array $params,
public ?string $sessionId = null
) {
//
}
/**
* @param array{id: mixed, jsonrpc?: mixed, method?: mixed, params?: array<string, mixed>} $jsonRequest
*
* @throws JsonRpcException
*/
public static function from(array $jsonRequest, ?string $sessionId = null): static
{
$requestId = $jsonRequest['id'];
if (! is_int($jsonRequest['id']) && ! is_string($jsonRequest['id'])) {
throw new JsonRpcException('Invalid Request: The [id] member must be a string, number.', -32600, $requestId);
}
if (! isset($jsonRequest['jsonrpc']) || $jsonRequest['jsonrpc'] !== '2.0') {
throw new JsonRpcException('Invalid Request: The [jsonrpc] member must be exactly [2.0].', -32600, $requestId);
}
if (! isset($jsonRequest['method']) || ! is_string($jsonRequest['method'])) {
throw new JsonRpcException('Invalid Request: The [method] member is required and must be a string.', -32600, $requestId);
}
return new static(
id: $requestId,
method: $jsonRequest['method'],
params: $jsonRequest['params'] ?? [],
sessionId: $sessionId,
);
}
public function cursor(): ?string
{
return $this->get('cursor');
}
public function get(string $key, mixed $default = null): mixed
{
return $this->params[$key] ?? $default;
}
/**
* @return array<string, mixed>|null
*/
public function meta(): ?array
{
return isset($this->params['_meta']) && is_array($this->params['_meta']) ? $this->params['_meta'] : null;
}
public function toRequest(): Request
{
return new Request($this->params['arguments'] ?? [], $this->sessionId, $this->meta());
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Transport;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, mixed>
*/
class JsonRpcResponse implements Arrayable
{
/**
* @param array<string, mixed> $content
*/
public function __construct(protected array $content = []) {}
/**
* @param array<string, mixed> $result
*/
public static function result(int|string $id, array $result): static
{
return new static([
'id' => $id,
'result' => $result === [] ? (object) [] : $result,
]);
}
/**
* @param array<string, mixed> $params
*/
public static function notification(string $method, array $params): static
{
return new static([
'method' => $method,
'params' => $params === [] ? (object) [] : $params,
]);
}
/**
* @param array<string, mixed>|null $data
*/
public static function error(string|int|null $id, int $code, string $message, ?array $data = null): static
{
$error = [
'code' => $code,
'message' => $message,
];
if ($data !== null) {
$error['data'] = $data;
}
return new static([
...$id === null ? [] : ['id' => $id],
'error' => $error,
]);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'jsonrpc' => '2.0',
...$this->content,
];
}
public function toJson(int $options = 0): string
{
return json_encode($this->toArray(), $options | JSON_UNESCAPED_UNICODE) ?: '';
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Transport;
use Closure;
use Laravel\Mcp\Server\Contracts\Transport;
class StdioTransport implements Transport
{
/**
* @param (Closure(string): void)|null $handler
*/
public function __construct(
protected string $sessionId,
protected ?Closure $handler = null,
) {
//
}
public function onReceive(Closure $handler): void
{
$this->handler = $handler;
}
public function send(string $message, ?string $sessionId = null): void
{
fwrite(STDOUT, $message.PHP_EOL);
}
public function run(): void
{
stream_set_blocking(STDIN, false);
while (! feof(STDIN)) {
if (($line = fgets(STDIN)) === false) {
usleep(10000);
continue;
}
if (is_callable($this->handler)) {
($this->handler)($line);
}
}
}
public function sessionId(): string
{
return $this->sessionId;
}
public function stream(Closure $stream): void
{
$stream();
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Ui;
use Illuminate\Contracts\Support\Arrayable;
use Laravel\Mcp\Server\Ui\Enums\Library;
/**
* @implements Arrayable<string, mixed>
*/
class AppMeta implements Arrayable
{
/**
* @param array<int, Library> $libraries
*/
public function __construct(
protected ?Csp $csp = null,
protected ?Permissions $permissions = null,
protected ?string $domain = null,
protected ?bool $prefersBorder = true,
protected array $libraries = [],
) {
//
}
public static function make(): static
{
return new static;
}
public function csp(Csp $csp): static
{
$this->csp = $csp;
return $this;
}
public function permissions(Permissions $permissions): static
{
$this->permissions = $permissions;
return $this;
}
public function domain(string $domain): static
{
$this->domain = $domain;
return $this;
}
public function prefersBorder(bool $prefersBorder = true): static
{
$this->prefersBorder = $prefersBorder;
return $this;
}
public function libraries(Library ...$libraries): static
{
$this->libraries = array_values($libraries);
return $this;
}
/**
* @return array<int, Library>
*/
public function getLibraries(): array
{
return $this->libraries;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$cspArray = $this->csp?->toArray() ?: [];
if ($this->libraries !== []) {
$libraryDomains = collect($this->libraries)
->map(fn (Library $lib): array => $lib->domains())
->flatten();
/** @var array<int, string> $existingDomains */
$existingDomains = $cspArray['resourceDomains'] ?? [];
$cspArray['resourceDomains'] = collect($existingDomains)
->merge($libraryDomains)
->unique()
->values()
->all();
}
return array_filter([
'csp' => $cspArray ?: null,
'permissions' => $this->permissions?->toArray() ?: null,
'domain' => $this->domain,
'prefersBorder' => $this->prefersBorder,
], fn (mixed $value): bool => $value !== null);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Ui;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, mixed>
*/
class Csp implements Arrayable
{
/**
* @param array<int, string>|null $connectDomains Domains the app may connect to via fetch, XHR, or WebSocket (CSP connect-src).
* @param array<int, string>|null $resourceDomains Domains the app may load images, scripts, styles, and fonts from (CSP default-src).
* @param array<int, string>|null $frameDomains Domains the app may embed as nested iframes (CSP frame-src).
* @param array<int, string>|null $baseUriDomains Allowed URLs for the document's base element (CSP base-uri).
*/
public function __construct(
protected ?array $connectDomains = null,
protected ?array $resourceDomains = null,
protected ?array $frameDomains = null,
protected ?array $baseUriDomains = null,
) {
//
}
public static function make(): static
{
return new static;
}
/**
* @param array<int, string> $domains
*/
public function connectDomains(array $domains): static
{
$this->connectDomains = $domains;
return $this;
}
/**
* @param array<int, string> $domains
*/
public function resourceDomains(array $domains): static
{
$this->resourceDomains = $domains;
return $this;
}
/**
* @param array<int, string> $domains
*/
public function frameDomains(array $domains): static
{
$this->frameDomains = $domains;
return $this;
}
/**
* @param array<int, string> $domains
*/
public function baseUriDomains(array $domains): static
{
$this->baseUriDomains = $domains;
return $this;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'connectDomains' => $this->connectDomains,
'resourceDomains' => $this->resourceDomains,
'frameDomains' => $this->frameDomains,
'baseUriDomains' => $this->baseUriDomains,
], fn (mixed $value): bool => $value !== null && $value !== []);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Ui\Enums;
enum Library: string
{
case Tailwind = 'tailwind';
case Alpine = 'alpine';
/**
* CSP resource domains required by this library.
*
* @return array<int, string>
*/
public function domains(): array
{
return match ($this) {
self::Tailwind => ['https://cdn.tailwindcss.com'],
self::Alpine => ['https://cdn.jsdelivr.net'],
};
}
/**
* HTML script tag to include in the document head.
*
* @return array<int, string>
*/
public function scriptTags(): array
{
return match ($this) {
self::Tailwind => [
'<script src="https://cdn.tailwindcss.com"></script>',
"<script>tailwind.config = { darkMode: ['selector', '[data-theme=\"dark\"]'] }</script>",
],
self::Alpine => [
'<style>[x-cloak] { display: none !important; }</style>',
'<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>',
],
};
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Ui\Enums;
enum Permission: string
{
case Camera = 'camera';
case Microphone = 'microphone';
case Geolocation = 'geolocation';
case ClipboardWrite = 'clipboardWrite';
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Ui\Enums;
enum Visibility: string
{
case Model = 'model';
case App = 'app';
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Server\Ui;
use Illuminate\Contracts\Support\Arrayable;
use Laravel\Mcp\Server\Ui\Enums\Permission;
use stdClass;
/**
* @implements Arrayable<string, mixed>
*/
class Permissions implements Arrayable
{
/** @var array<int, Permission> */
protected array $enabled = [];
public static function make(): static
{
return new static;
}
public function camera(): static
{
return $this->allow(Permission::Camera);
}
public function microphone(): static
{
return $this->allow(Permission::Microphone);
}
public function geolocation(): static
{
return $this->allow(Permission::Geolocation);
}
public function clipboardWrite(): static
{
return $this->allow(Permission::ClipboardWrite);
}
public function allow(Permission ...$permissions): static
{
array_push($this->enabled, ...$permissions);
return $this;
}
/**
* @return array<string, stdClass>
*/
public function toArray(): array
{
$result = [];
foreach ($this->enabled as $permission) {
$result[$permission->value] = new stdClass;
}
return $result;
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Support;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Stringable;
class UriTemplate implements Stringable
{
private const MAX_TEMPLATE_LENGTH = 1_000_000;
private const MAX_VARIABLE_LENGTH = 1_000_000;
private const MAX_TEMPLATE_EXPRESSIONS = 10_000;
private const MAX_REGEX_LENGTH = 1_000_000;
private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/';
/** @var list<string> */
private array $variableNames = [];
private ?string $compiledRegex = null;
public function __construct(private readonly string $template)
{
$this->validateLength($template, self::MAX_TEMPLATE_LENGTH, 'Template');
if (! preg_match(self::URI_TEMPLATE_PATTERN, $template)) {
throw new InvalidArgumentException('Invalid URI template: must be a valid URI template with at least one placeholder.');
}
$this->variableNames = $this->extractVariableNames($template);
}
/**
* @return array<string, string>|null
*/
public function match(string $uri): ?array
{
$this->validateLength($uri, self::MAX_TEMPLATE_LENGTH, 'URI');
$this->compiledRegex ??= $this->compileRegex();
if (! preg_match($this->compiledRegex, $uri, $matches)) {
return null;
}
$result = [];
foreach ($this->variableNames as $i => $name) {
$result[$name] = $matches[$i + 1] ?? '';
}
return $result;
}
public function __toString(): string
{
return $this->template;
}
private function validateLength(string $str, int $max, string $context): void
{
throw_if(
Str::length($str) > $max,
InvalidArgumentException::class,
sprintf('%s exceeds the maximum length of %d characters (received %d)', $context, $max, Str::length($str))
);
}
/**
* @return list<string>
*/
private function extractVariableNames(string $template): array
{
$expressionCount = 0;
$names = [];
if (! preg_match_all('/\{(\w+)}/', $template, $matches)) {
return [];
}
foreach ($matches[1] as $name) {
$expressionCount++;
throw_if(
$expressionCount > self::MAX_TEMPLATE_EXPRESSIONS,
InvalidArgumentException::class,
sprintf('Template contains too many expressions (max %d)', self::MAX_TEMPLATE_EXPRESSIONS)
);
$this->validateLength($name, self::MAX_VARIABLE_LENGTH, 'Variable name');
$names[] = $name;
}
return $names;
}
private function compileRegex(): string
{
$regexParts = [];
$segments = preg_split('/(\{\w+})/', $this->template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
throw_unless(
$segments,
InvalidArgumentException::class,
'Failed to compile URI template regex: preg_split error'
);
foreach ($segments as $segment) {
$isVariable = preg_match('/^\{(\w+)}$/', $segment);
throw_if(
$isVariable === false,
InvalidArgumentException::class,
'Failed to validate template segment: preg_match error'
);
$regexParts[] = $isVariable === 1 ? '([^/]+)' : preg_quote($segment, '#');
}
$pattern = '#^'.implode('', $regexParts).'$#';
$this->validateLength($pattern, self::MAX_REGEX_LENGTH, 'Generated regex pattern');
return $pattern;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Laravel\Mcp\Support;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class ValidationMessages
{
public static function from(ValidationException $exception): string
{
$messages = collect($exception->errors())->flatten()->all();
if (count($messages) === 0 || ! is_string($messages[0])) {
$translator = Validator::getTranslator();
return $translator->get('The given data was invalid.');
}
return implode(' ', $messages);
}
}