refactor: susun semula struktur folder — Laravel source ke src/
This commit is contained in:
21
vendor/laravel/mcp/LICENSE.md
vendored
Normal file
21
vendor/laravel/mcp/LICENSE.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Taylor Otwell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
35
vendor/laravel/mcp/README.md
vendored
Normal file
35
vendor/laravel/mcp/README.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
<p align="center">
|
||||
<img alt="Laravel MCP Logo Light Mode" src="/art/logo-light-mode.svg#gh-light-mode-only"/>
|
||||
<img alt="Laravel MCP Logo Dark Mode" src="/art/logo-dark-mode.svg#gh-dark-mode-only"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/mcp/actions/workflows/tests.yml"><img src="https://github.com/laravel/mcp/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
|
||||
<a href="https://packagist.org/packages/laravel/mcp"><img src="https://img.shields.io/packagist/dt/laravel/mcp" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/mcp"><img src="https://img.shields.io/packagist/v/laravel/mcp" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/mcp"><img src="https://img.shields.io/packagist/l/laravel/mcp" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
|
||||
Laravel MCP allows you to rapidly build MCP servers for your Laravel applications. MCP servers allow AI clients to interact with your Laravel application through the [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro).
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation for Laravel MCP can be found on the [Laravel website](https://laravel.com/docs/mcp).
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to Laravel MCP! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
Please review [our security policy](https://github.com/laravel/mcp/security/policy) on how to report security vulnerabilities.
|
||||
|
||||
## License
|
||||
|
||||
Laravel MCP is open-sourced software licensed under the [MIT license](LICENSE.md).
|
||||
100
vendor/laravel/mcp/composer.json
vendored
Normal file
100
vendor/laravel/mcp/composer.json
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
"description": "Rapidly build MCP servers for your Laravel applications.",
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"laravel"
|
||||
],
|
||||
"homepage": "https://github.com/laravel/mcp",
|
||||
"license": "MIT",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/mcp/issues",
|
||||
"source": "https://github.com/laravel/mcp"
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/json-schema": "^12.41.1|^13.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.20",
|
||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||
"pestphp/pest": "^3.8.5|^4.3.2",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.2.4"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Mcp\\": "src/",
|
||||
"Laravel\\Mcp\\Server\\": "src/Server/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Workbench\\App\\": "workbench/app/",
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
||||
],
|
||||
"aliases": {
|
||||
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"@clear",
|
||||
"@prepare"
|
||||
],
|
||||
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
|
||||
"prepare": "@php vendor/bin/testbench package:discover --ansi",
|
||||
"build": "@php vendor/bin/testbench workbench:build --ansi",
|
||||
"serve": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@build",
|
||||
"@php vendor/bin/testbench serve --ansi"
|
||||
],
|
||||
"minify": "npx terser resources/js/mcp-sdk.js --compress --mangle --output resources/js/mcp-sdk.min.js",
|
||||
"lint": [
|
||||
"pint",
|
||||
"rector"
|
||||
],
|
||||
"test:lint": [
|
||||
"pint --test",
|
||||
"rector --dry-run"
|
||||
],
|
||||
"test:unit": "pest --ci --coverage --min=92.5",
|
||||
"test:types": "phpstan --no-progress",
|
||||
"test": [
|
||||
"@test:lint",
|
||||
"@test:unit",
|
||||
"@test:types"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
54
vendor/laravel/mcp/config/mcp.php
vendored
Normal file
54
vendor/laravel/mcp/config/mcp.php
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These domains are the domains that OAuth clients are permitted to use
|
||||
| for redirect URIs. Each domain should be specified with its scheme
|
||||
| and host. Domains not in this list will raise validation errors.
|
||||
|
|
||||
| An "*" may be used to allow all domains.
|
||||
|
|
||||
*/
|
||||
|
||||
'redirect_domains' => [
|
||||
'*',
|
||||
// 'https://example.com',
|
||||
// 'http://localhost',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed Custom Schemes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Native desktop OAuth clients like Cursor and VS Code use private-use URI
|
||||
| schemes (RFC 8252) for redirect callbacks instead of standard schemes
|
||||
| like HTTPS. Here, you may list which custom schemes you will allow.
|
||||
|
|
||||
*/
|
||||
|
||||
'custom_schemes' => [
|
||||
// 'claude',
|
||||
// 'cursor',
|
||||
// 'vscode',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authorization Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the OAuth authorization server issuer identifier
|
||||
| per RFC 8414. This value appears in your protected resource and auth
|
||||
| server metadata endpoints. When null, this defaults to `url('/')`.
|
||||
|
|
||||
*/
|
||||
|
||||
'authorization_server' => null,
|
||||
|
||||
];
|
||||
18
vendor/laravel/mcp/pint.json
vendored
Normal file
18
vendor/laravel/mcp/pint.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"preset": "laravel",
|
||||
"rules": {
|
||||
"global_namespace_import": {
|
||||
"import_classes": true,
|
||||
"import_constants": true
|
||||
},
|
||||
"phpdoc_types": false,
|
||||
"combine_consecutive_issets": true,
|
||||
"combine_consecutive_unsets": true,
|
||||
"explicit_string_variable": true,
|
||||
"method_chaining_indentation": true,
|
||||
"no_binary_string": false,
|
||||
"strict_comparison": true,
|
||||
"strict_param": true,
|
||||
"ternary_to_null_coalescing": true
|
||||
}
|
||||
}
|
||||
30
vendor/laravel/mcp/rector.php
vendored
Normal file
30
vendor/laravel/mcp/rector.php
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\CodingStyle\Rector\ClassLike\NewlineBetweenClassLikeStmtsRector;
|
||||
use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector;
|
||||
use Rector\Php81\Rector\Property\ReadOnlyPropertyRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSkip([
|
||||
ReadOnlyPropertyRector::class,
|
||||
EncapsedStringsToSprintfRector::class,
|
||||
NewlineBetweenClassLikeStmtsRector::class,
|
||||
StringClassNameToClassConstantRector::class => [
|
||||
__DIR__.'/src/Server/Http/Controllers/OAuthRegisterController.php',
|
||||
],
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
codeQuality: true,
|
||||
codingStyle: true,
|
||||
typeDeclarations: true,
|
||||
earlyReturn: true,
|
||||
)->withPhpSets(php82: true);
|
||||
106
vendor/laravel/mcp/resources/boost/skills/mcp-development/SKILL.blade.php
vendored
Normal file
106
vendor/laravel/mcp/resources/boost/skills/mcp-development/SKILL.blade.php
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: mcp-development
|
||||
description: "Use this skill for Laravel MCP development. Trigger when creating or editing MCP tools, resources, prompts, servers, or UI apps in Laravel projects. Covers: artisan make:mcp-* generators, routes/ai.php, Tool/Resource/Prompt/AppResource classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, MCP debugging, MCP UI apps, the x-mcp::app Blade component, createMcpApp(), default AppResource handle() auto-infers view from class name, Response::view(), AppMeta/Csp/Permissions/appMeta() configuration, #[RendersApp] attribute, Library enum for CDN libraries (Tailwind, Alpine), and host theming via CSS variables. Use this whenever the user mentions MCP apps, MCP UI, interactive MCP resources, styling MCP apps with Tailwind or Alpine, or building visual interfaces for AI agents."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# MCP Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||
|
||||
For MCP UI apps (interactive HTML resources), read `references/app.md` — it covers the full architecture, host theming CSS variables, tool-to-UI linking patterns, library scripts (Tailwind, Alpine via `Library`), and real-world examples.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
Register MCP servers in `routes/ai.php`:
|
||||
|
||||
@boostsnippet("Register MCP Server", "php")
|
||||
use Laravel\Mcp\Facades\Mcp;
|
||||
|
||||
Mcp::web();
|
||||
@endboostsnippet
|
||||
|
||||
### Creating MCP Primitives
|
||||
|
||||
```bash
|
||||
{{ $assist->artisanCommand('make:mcp-tool ToolName') }} # Create a tool
|
||||
{{ $assist->artisanCommand('make:mcp-resource ResourceName') }} # Create a resource
|
||||
{{ $assist->artisanCommand('make:mcp-prompt PromptName') }} # Create a prompt
|
||||
{{ $assist->artisanCommand('make:mcp-server ServerName') }} # Create a server
|
||||
{{ $assist->artisanCommand('make:mcp-app-resource DashboardApp') }} # Create a UI app (2 files)
|
||||
```
|
||||
|
||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||
|
||||
### Tools
|
||||
|
||||
@boostsnippet("MCP Tool Example", "php")
|
||||
use Illuminate\Json\Schema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
class MyTool extends Tool
|
||||
{
|
||||
protected string $description = 'Describe what this tool does';
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->description('The name parameter')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$request->validate(['name' => 'required|string']);
|
||||
|
||||
return Response::text('Hello, '.$request->get('name'));
|
||||
}
|
||||
}
|
||||
@endboostsnippet
|
||||
|
||||
### Registering Primitives in a Server
|
||||
|
||||
@boostsnippet("Register Primitives in MCP Server", "php")
|
||||
use Laravel\Mcp\Server;
|
||||
|
||||
class AppServer extends Server
|
||||
{
|
||||
protected array $tools = [
|
||||
\App\Mcp\Tools\MyTool::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
\App\Mcp\Resources\MyResource::class,
|
||||
];
|
||||
|
||||
protected array $prompts = [
|
||||
\App\Mcp\Prompts\MyPrompt::class,
|
||||
];
|
||||
}
|
||||
@endboostsnippet
|
||||
|
||||
## MCP UI Apps
|
||||
|
||||
For MCP UI apps, read `references/app.md` — it covers quick start examples, full architecture, AppMeta/Csp/Permissions, `#[RendersApp]` tool linking, library scripts (Tailwind/Alpine via `Library`), host theming CSS variables, and real-world patterns.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check `routes/ai.php` for proper registration
|
||||
2. Test tool via MCP client
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Running `mcp:start` command (it hangs waiting for input)
|
||||
- Using HTTPS locally with Node-based MCP clients
|
||||
- Not using `search-docs` for the latest MCP documentation
|
||||
- Not registering MCP server routes in `routes/ai.php`
|
||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically
|
||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||
940
vendor/laravel/mcp/resources/boost/skills/mcp-development/references/app.md
vendored
Normal file
940
vendor/laravel/mcp/resources/boost/skills/mcp-development/references/app.md
vendored
Normal file
@@ -0,0 +1,940 @@
|
||||
# MCP UI Apps Reference
|
||||
|
||||
## Quick Start
|
||||
|
||||
`make:mcp-app-resource DashboardApp` generates two files — a PHP registration stub and a Blade view. The entire app lives in the Blade view.
|
||||
|
||||
**PHP class** — renders the Blade view. The view name is auto-inferred from the class name (`mcp.<kebab-class-name>`), so the generated stub needs no changes unless you're passing additional server-side data:
|
||||
|
||||
```php
|
||||
class DashboardApp extends AppResource
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::view('mcp.dashboard-app', [
|
||||
'title' => $this->title(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Blade view** — HTML structure + inline JS, everything in one file:
|
||||
|
||||
```blade
|
||||
<x-mcp::app title="Dashboard App">
|
||||
<x-slot:head>
|
||||
<script type="module">
|
||||
createMcpApp(async (app) => {
|
||||
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-slot:head>
|
||||
|
||||
<div id="app">
|
||||
<h1>Dashboard App</h1>
|
||||
<button id="run-btn">Run</button>
|
||||
<p id="output"></p>
|
||||
</div>
|
||||
</x-mcp::app>
|
||||
```
|
||||
|
||||
`createMcpApp` is a global pre-bundled by the package — no npm install, no imports, no Vite required. It handles connection, error handling, and host theming automatically.
|
||||
|
||||
---
|
||||
|
||||
## Core Concept: Tool + Resource
|
||||
|
||||
Every MCP App is built from two parts linked together:
|
||||
|
||||
- **Tool** — called by the LLM or host. Returns a text/data response and tells the host which UI resource to render via `_meta.ui.resourceUri`.
|
||||
- **AppResource** — serves the self-contained HTML app. The host fetches it after the tool is called and renders it in a sandboxed iframe.
|
||||
|
||||
```
|
||||
LLM calls Tool
|
||||
└─► Tool response includes _meta.ui.resourceUri → "ui://dashboard-app"
|
||||
└─► Host fetches AppResource at that URI
|
||||
└─► Host renders HTML in sandboxed iframe
|
||||
└─► createMcpApp() connects the iframe back to the server
|
||||
└─► UI calls app-only tools to load/refresh data
|
||||
```
|
||||
|
||||
The link is declared once with `#[RendersApp]` on the tool:
|
||||
|
||||
```php
|
||||
#[RendersApp(resource: DashboardApp::class)]
|
||||
class ShowDashboard extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::text('Dashboard loaded.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After that, the host handles fetching and rendering the resource automatically — you never reference the URI by hand.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
MCP Apps add interactive UI to the Model Context Protocol. The server returns self-contained HTML with all JS/CSS inlined. The host renders it in a sandboxed iframe. Apps communicate back via `createMcpApp()` — a pre-bundled global implementing the MCP UI PostMessage protocol.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Host (Claude, ChatGPT, VS Code) │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ Sandboxed iframe │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ Your MCP App (HTML/JS/CSS) │ │ │
|
||||
│ │ │ - Rendered by AppResource │ │ │
|
||||
│ │ │ - Single self-contained HTML │ │ │
|
||||
│ │ │ - Themed via host CSS vars │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ MCP Protocol (JSON-RPC)
|
||||
┌──────────────────▼──────────────────────────┐
|
||||
│ Laravel MCP Server │
|
||||
│ - AppResource → self-contained HTML │
|
||||
│ - Tool #[RendersApp] → triggers UI display │
|
||||
│ - resources/read → serves HTML + _meta.ui │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The server automatically advertises `io.modelcontextprotocol/ui` capability when any `AppResource` is registered. The client declares support in `capabilities.extensions["io.modelcontextprotocol/ui"]` during the initialize handshake.
|
||||
|
||||
---
|
||||
|
||||
## Server-Side
|
||||
|
||||
Minimal case — `handle()` renders the Blade view, entire app lives there:
|
||||
|
||||
```php
|
||||
class DashboardApp extends AppResource
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::view('mcp.dashboard-app', [
|
||||
'title' => $this->title(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Auto-renders `resources/views/mcp/dashboard-app.blade.php` with `$title` available via `$this->title()`.
|
||||
|
||||
Override `handle()` only when passing additional server-side data:
|
||||
|
||||
```php
|
||||
class AnalyticsDashboard extends AppResource
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::view('mcp.analytics-dashboard', [
|
||||
'title' => $this->title(),
|
||||
'metrics' => Metric::latest()->take(10)->get(),
|
||||
'totalUsers' => User::count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Response::view($view, $data = [], $mergeData = [])` renders a Blade view and returns it as text.
|
||||
|
||||
`Response::html($path)` reads an HTML file from disk and returns its content. Relative paths resolve via `resource_path()`:
|
||||
|
||||
```php
|
||||
class StaticApp extends AppResource
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::html('mcp/static-app.html');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AppMeta Configuration
|
||||
|
||||
The simplest way to configure UI metadata is via the `#[AppMeta]` attribute directly on your resource class:
|
||||
|
||||
```php
|
||||
use Laravel\Mcp\Server\Attributes\AppMeta;
|
||||
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||
|
||||
#[AppMeta(
|
||||
connectDomains: ['https://api.stripe.com'],
|
||||
permissions: [Permission::Camera, Permission::ClipboardWrite],
|
||||
prefersBorder: true,
|
||||
libraries: [Library::Tailwind, Library::Alpine],
|
||||
)]
|
||||
class PaymentsResource extends AppResource
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
For dynamic or computed configuration, override `appMeta()` instead:
|
||||
|
||||
```php
|
||||
use Laravel\Mcp\Server\Ui\AppMeta;
|
||||
|
||||
public function appMeta(): AppMeta
|
||||
{
|
||||
return AppMeta::make()
|
||||
->csp(Csp::make()->connectDomains(config('services.api.domains')))
|
||||
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||
->libraries(Library::Tailwind)
|
||||
->domain('sandbox.example.com');
|
||||
}
|
||||
```
|
||||
|
||||
#### Permission Enum
|
||||
|
||||
Use the `Permission` enum for type-safe permission configuration:
|
||||
|
||||
```php
|
||||
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||
|
||||
Permission::Camera // 'camera'
|
||||
Permission::Microphone // 'microphone'
|
||||
Permission::Geolocation // 'geolocation'
|
||||
Permission::ClipboardWrite // 'clipboardWrite'
|
||||
```
|
||||
|
||||
#### Csp
|
||||
|
||||
Controls what external domains the iframe can access:
|
||||
|
||||
```php
|
||||
Csp::make()
|
||||
->connectDomains(['https://api.example.com']) // fetch, XHR, WebSocket origins
|
||||
->resourceDomains(['https://cdn.example.com']) // images, scripts, fonts, media
|
||||
->frameDomains(['https://embed.example.com']) // nested iframe origins
|
||||
->baseUriDomains(['https://base.example.com']); // base URI origins
|
||||
```
|
||||
|
||||
#### Permissions
|
||||
|
||||
```php
|
||||
Permissions::make()->allow(Permission::Camera, Permission::ClipboardWrite);
|
||||
|
||||
Permissions::make()
|
||||
->camera()
|
||||
->microphone()
|
||||
->geolocation()
|
||||
->clipboardWrite();
|
||||
```
|
||||
|
||||
Each enabled permission serializes as `"camera": {}` per the MCP spec.
|
||||
|
||||
#### AppMeta
|
||||
|
||||
```php
|
||||
AppMeta::make()
|
||||
->csp(Csp::make()->connectDomains([...]))
|
||||
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||
->libraries(Library::Tailwind, Library::Alpine)
|
||||
->domain('sandbox.example.com') // dedicated sandbox origin (OAuth/CORS)
|
||||
->prefersBorder(false);
|
||||
```
|
||||
|
||||
`prefersBorder` defaults to `true`. `toArray()` omits null fields and empty nested objects. Library CDN domains are automatically merged into `csp.resourceDomains`.
|
||||
|
||||
#### domain
|
||||
|
||||
The `domain` field provides a stable origin that external APIs can allowlist for CORS. It is automatically resolved from `config('app.url')` (your `APP_URL` env variable) via `resolvedAppMeta()`, so most apps need no configuration. Override only when a resource needs a different origin:
|
||||
|
||||
```php
|
||||
#[AppMeta(domain: 'custom.example.com')]
|
||||
class PaymentsResource extends AppResource
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Library Scripts
|
||||
|
||||
The `libraries` parameter adds pre-configured CDN scripts to the `<head>` of your app. Available libraries:
|
||||
|
||||
```php
|
||||
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||
|
||||
Library::Tailwind // Tailwind CSS CDN + dark mode config
|
||||
Library::Alpine // Alpine.js CDN + x-cloak style
|
||||
```
|
||||
|
||||
When libraries are specified, the package automatically:
|
||||
|
||||
1. Injects the CDN `<script>` tags into the Blade view's `<head>` (after the MCP SDK, before your `<x-slot:head>`)
|
||||
2. Merges each library's CDN domains into `csp.resourceDomains` so the host allows loading them
|
||||
|
||||
Via attribute:
|
||||
|
||||
```php
|
||||
#[AppMeta(libraries: [Library::Tailwind])]
|
||||
class StyledApp extends AppResource
|
||||
{
|
||||
// Tailwind is available in the Blade view — no extra setup
|
||||
}
|
||||
```
|
||||
|
||||
Via fluent builder:
|
||||
|
||||
```php
|
||||
public function appMeta(): AppMeta
|
||||
{
|
||||
return AppMeta::make()
|
||||
->libraries(Library::Tailwind, Library::Alpine);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Layer
|
||||
|
||||
### `<x-mcp::app>` Blade Component
|
||||
|
||||
Renders a complete self-contained HTML document with the MCP SDK inlined. `createMcpApp` is available globally.
|
||||
|
||||
```blade
|
||||
<x-mcp::app title="Dashboard App">
|
||||
<x-slot:head>
|
||||
<script type="module">
|
||||
createMcpApp(async (app) => {
|
||||
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-slot:head>
|
||||
|
||||
<div id="app">
|
||||
<button id="run-btn">Run</button>
|
||||
<p id="output"></p>
|
||||
</div>
|
||||
</x-mcp::app>
|
||||
```
|
||||
|
||||
**Props and slots:**
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------------- | ------------- | ---------------------------------------------------- |
|
||||
| `title` | Prop | Sets `<title>`. Optional. |
|
||||
| `head` | Named slot | Injected into `<head>` after the inlined SDK script. |
|
||||
| Default slot | Slot | Body content. |
|
||||
| `$attributes` | Attribute bag | Forwarded to `<body>` (e.g. `class="dark"`). |
|
||||
|
||||
The SDK is loaded from the `mcp.sdk` singleton (registered by `McpServiceProvider`) and inlined directly in a `<script>` tag. Library scripts (Tailwind, Alpine) configured via `#[AppMeta]` are injected after the SDK and before the `head` slot.
|
||||
|
||||
Publish the component: `php artisan vendor:publish --tag=mcp-views`.
|
||||
|
||||
To pass server-side data to JS, embed it as `data-*` attributes:
|
||||
|
||||
```blade
|
||||
<div id="app" data-users="{{ $users->toJson() }}">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
const users = JSON.parse(document.getElementById("app").dataset.users);
|
||||
```
|
||||
|
||||
## Client-Side
|
||||
|
||||
This package provides a simple MCP client library to easily work with client interactions.
|
||||
|
||||
### createMcpApp
|
||||
|
||||
Pre-bundled and inlined automatically — no npm install or imports required.
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
// app is ready — connection established, theming applied
|
||||
});
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
#### app.callServerTool()
|
||||
|
||||
Accepts an object or positional arguments:
|
||||
|
||||
```js
|
||||
// Object form
|
||||
const result = await app.callServerTool({ name: 'get-analytics', arguments: { dateRange: '7d' } });
|
||||
|
||||
// Positional form
|
||||
const result = await app.callServerTool('get-analytics', { dateRange: '7d' });
|
||||
|
||||
// result structure depends on the server's tool response
|
||||
const text = result.content[0]?.text ?? "";
|
||||
```
|
||||
|
||||
All tool results share a standard structure:
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------- | --------- | ------------------------------------------------------------------------- |
|
||||
| `content` | `Array` | Content items returned by the tool (each has `type` and `text` or `data`) |
|
||||
| `isError` | `boolean` | `true` when the tool returned an error response |
|
||||
|
||||
Always check `result.isError` before consuming `content`. See [Error Handling](#error-handling) for a full example.
|
||||
|
||||
### Resources
|
||||
|
||||
#### app.listResources()
|
||||
|
||||
```js
|
||||
const resources = await app.listResources();
|
||||
// or with cursor for pagination
|
||||
const resources = await app.listResources("cursor-value");
|
||||
// or object form
|
||||
const resources = await app.listResources({ cursor: "cursor-value" });
|
||||
```
|
||||
|
||||
#### app.readResource()
|
||||
|
||||
```js
|
||||
const resource = await app.readResource("ui://my-resource");
|
||||
// or object form
|
||||
const resource = await app.readResource({ uri: "ui://my-resource" });
|
||||
```
|
||||
|
||||
### Messaging
|
||||
|
||||
#### app.sendMessage()
|
||||
|
||||
Send a message to the model (creates a conversation turn):
|
||||
|
||||
```js
|
||||
// Object form with structured content
|
||||
await app.sendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "User submitted the form." }],
|
||||
});
|
||||
|
||||
// Shorthand — plain string content with optional role (defaults to 'user')
|
||||
await app.sendMessage("User submitted the form.");
|
||||
await app.sendMessage("System event occurred.", "user");
|
||||
```
|
||||
|
||||
### Host Context
|
||||
|
||||
#### app.getHostContext()
|
||||
|
||||
Returns the current host context, including theme and style variables:
|
||||
|
||||
```js
|
||||
const ctx = app.getHostContext();
|
||||
ctx?.theme; // 'light' | 'dark'
|
||||
ctx?.styles?.variables; // CSS variable map from host
|
||||
ctx?.styles?.css?.fonts; // font CSS from host
|
||||
```
|
||||
|
||||
#### app.getHostInfo()
|
||||
|
||||
```js
|
||||
const info = app.getHostInfo();
|
||||
```
|
||||
|
||||
#### app.getHostCapabilities()
|
||||
|
||||
```js
|
||||
const caps = app.getHostCapabilities();
|
||||
```
|
||||
|
||||
### Navigation & Files
|
||||
|
||||
#### app.openLink()
|
||||
|
||||
```js
|
||||
await app.openLink("https://example.com");
|
||||
// or object form
|
||||
await app.openLink({ url: "https://example.com" });
|
||||
```
|
||||
|
||||
#### app.downloadFile()
|
||||
|
||||
```js
|
||||
await app.downloadFile("file contents here");
|
||||
// or object form
|
||||
await app.downloadFile({ contents: "file contents here" });
|
||||
```
|
||||
|
||||
### Display
|
||||
|
||||
#### app.requestDisplayMode()
|
||||
|
||||
```js
|
||||
await app.requestDisplayMode("fullscreen");
|
||||
// or object form
|
||||
await app.requestDisplayMode({ mode: "fullscreen" });
|
||||
```
|
||||
|
||||
#### app.resize() / app.autoResize()
|
||||
|
||||
`resize()` sends a one-time size notification. `autoResize()` uses `ResizeObserver` to continuously notify the host of size changes. It returns a cleanup function that disconnects the observer — useful if you need to stop observing before teardown. The observer is also automatically disconnected on teardown.
|
||||
|
||||
```js
|
||||
const stopObserving = app.autoResize();
|
||||
|
||||
// Later, if needed:
|
||||
stopObserving();
|
||||
```
|
||||
|
||||
### Model Context
|
||||
|
||||
#### app.updateModelContext()
|
||||
|
||||
```js
|
||||
await app.updateModelContext({ key: "value" });
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
#### app.requestTeardown()
|
||||
|
||||
Sends a teardown notification to the host.
|
||||
|
||||
```js
|
||||
app.requestTeardown();
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
#### app.sendLog()
|
||||
|
||||
```js
|
||||
// Positional form
|
||||
await app.sendLog("info", "Processing started", "my-logger");
|
||||
|
||||
// Object form
|
||||
await app.sendLog({
|
||||
level: "info",
|
||||
data: "Processing started",
|
||||
logger: "my-logger",
|
||||
});
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
Register callbacks for host-side events. Tool input/result/cancelled events are queued until a handler is registered, then flushed.
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
app.onToolInput((params) => {
|
||||
/* tool input received */
|
||||
});
|
||||
app.onToolInputPartial((params) => {
|
||||
/* partial tool input */
|
||||
});
|
||||
app.onToolResult((params) => {
|
||||
/* tool result received */
|
||||
});
|
||||
app.onToolCancelled((params) => {
|
||||
/* tool was cancelled */
|
||||
});
|
||||
app.onHostContextChanged((ctx) => {
|
||||
/* theme/styles changed */
|
||||
});
|
||||
app.onTeardown(async () => {
|
||||
/* cleanup before teardown */
|
||||
});
|
||||
app.onCallTool(async (params) => {
|
||||
/* host requests tool call */
|
||||
});
|
||||
app.onListTools(async (params) => {
|
||||
/* host requests tool list */
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Host Theming
|
||||
|
||||
`createMcpApp` automatically applies host theming on connect and on context change:
|
||||
|
||||
- Sets `data-theme` attribute and `color-scheme` on `<html>`
|
||||
- Applies CSS variables from `hostContext.styles.variables` to `:root`
|
||||
- Injects font CSS from `hostContext.styles.css.fonts` into a `<style>` tag
|
||||
|
||||
The specific CSS variables available depend on the host. Always provide fallback values — use `light-dark()` for theme-aware defaults:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-background-primary: light-dark(#ffffff, #171717);
|
||||
--color-text-primary: light-dark(#171717, #fafafa);
|
||||
--color-text-secondary: light-dark(#525252, #a3a3a3);
|
||||
--color-border-primary: light-dark(#e5e5e5, #404040);
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--border-radius-md: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool-to-UI Linking
|
||||
|
||||
### #[RendersApp] Attribute
|
||||
|
||||
Associates a Tool with a UI Resource. When the tool is called, the host fetches and renders the linked resource.
|
||||
|
||||
```php
|
||||
use Laravel\Mcp\Server\Attributes\RendersApp;
|
||||
use Laravel\Mcp\Server\Ui\Enums\Visibility;
|
||||
|
||||
// Both model and app can call this tool (default)
|
||||
#[RendersApp(resource: DashboardApp::class)]
|
||||
class ShowDashboard extends Tool { ... }
|
||||
|
||||
// Only the app can call this tool (private to the UI)
|
||||
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||
class RefreshDashboardData extends Tool { ... }
|
||||
```
|
||||
|
||||
**Visibility:**
|
||||
|
||||
The `Visibility` enum (`Laravel\Mcp\Server\Ui\Enums\Visibility`) has two cases: `Model` and `App`. The default is `[Visibility::Model, Visibility::App]`.
|
||||
|
||||
| Visibility | Model | App | Use case |
|
||||
| -------------------------------------- | ----- | --- | ------------------------------------------------------ |
|
||||
| `[Visibility::Model, Visibility::App]` | Yes | Yes | Primary tools that trigger UI display |
|
||||
| `[Visibility::App]` | No | Yes | Backend actions the UI calls (refresh, save, paginate) |
|
||||
| `[Visibility::Model]` | Yes | No | Model-only tools linked to a UI |
|
||||
|
||||
### Primary + Private Pattern
|
||||
|
||||
```php
|
||||
#[RendersApp(resource: DashboardApp::class)]
|
||||
class ShowDashboard extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::text('Dashboard loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||
class GetDashboardMetrics extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::json(Metric::latest()->take(50)->get());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```php
|
||||
it('returns html content', function () {
|
||||
MyServer::readResource(DashboardApp::class)
|
||||
->assertSee('<div id="app">');
|
||||
});
|
||||
|
||||
it('has correct mime type and uri scheme', function () {
|
||||
$resource = new DashboardApp;
|
||||
$data = $resource->toArray();
|
||||
|
||||
expect($data['mimeType'])->toBe('text/html;profile=mcp-app')
|
||||
->and($data['_meta']['ui'])->toBeArray()
|
||||
->and($resource->uri())->toStartWith('ui://');
|
||||
});
|
||||
|
||||
it('configures ui meta correctly', function () {
|
||||
$meta = (new DashboardApp)->resolvedAppMeta();
|
||||
|
||||
expect($meta['csp']['connectDomains'])->toContain('https://api.example.com')
|
||||
->and($meta['permissions'])->toHaveKey('clipboardWrite');
|
||||
});
|
||||
|
||||
it('includes ui metadata in tool listing', function () {
|
||||
MyServer::listTools()->assertSee('show-dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
### Real-time Polling
|
||||
|
||||
Use app-only tools to fetch fresh data at regular intervals from the UI:
|
||||
|
||||
```php
|
||||
#[RendersApp(resource: MonitorApp::class, visibility: [Visibility::App])]
|
||||
class GetMonitorData extends Tool
|
||||
{
|
||||
protected string $description = 'Fetch latest monitor metrics';
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return Response::json([
|
||||
'cpu' => sys_getloadavg()[0],
|
||||
'memory' => memory_get_usage(true),
|
||||
'timestamp' => now()->toISOString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
async function poll() {
|
||||
const result = await app.callServerTool('get-monitor-data');
|
||||
const data = JSON.parse(result.content[0]?.text ?? '{}');
|
||||
document.getElementById('cpu').textContent = data.cpu;
|
||||
}
|
||||
|
||||
setInterval(poll, 2000);
|
||||
poll();
|
||||
});
|
||||
```
|
||||
|
||||
### Chunked Data Loading
|
||||
|
||||
For large datasets, implement pagination via app-only tools:
|
||||
|
||||
```php
|
||||
#[RendersApp(resource: LogViewerApp::class, visibility: [Visibility::App])]
|
||||
class GetLogChunk extends Tool
|
||||
{
|
||||
protected string $description = 'Fetch a chunk of log entries';
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'offset' => $schema->integer()->description('Byte offset to start from')->required(),
|
||||
'limit' => $schema->integer()->description('Max bytes to return'),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$request->validate(['offset' => 'required|integer', 'limit' => 'integer']);
|
||||
|
||||
$offset = $request->get('offset');
|
||||
$limit = $request->get('limit', 500_000);
|
||||
$content = Storage::get('logs/app.log');
|
||||
$chunk = substr($content, $offset, $limit);
|
||||
|
||||
return Response::json([
|
||||
'data' => $chunk,
|
||||
'offset' => $offset,
|
||||
'totalBytes' => strlen($content),
|
||||
'hasMore' => ($offset + $limit) < strlen($content),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Binary Resource Serving
|
||||
|
||||
Deliver images and binary content through MCP resources using `Response::blob()`:
|
||||
|
||||
```php
|
||||
#[RendersApp(resource: GalleryApp::class, visibility: [Visibility::App])]
|
||||
class GetImage extends Tool
|
||||
{
|
||||
protected string $description = 'Fetch an image by ID';
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$request->validate(['id' => 'required|integer']);
|
||||
|
||||
$image = Image::findOrFail($request->get('id'));
|
||||
$data = base64_encode(Storage::get($image->path));
|
||||
|
||||
return Response::blob($data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the client, convert the base64 blob to a data URI for rendering:
|
||||
|
||||
```js
|
||||
const result = await app.callServerTool('get-image', { id: 42 });
|
||||
const blob = result.content[0];
|
||||
img.src = `data:${blob.mimeType};base64,${blob.data}`;
|
||||
```
|
||||
|
||||
### Streaming Argument Previews
|
||||
|
||||
Use `onToolInputPartial` to show previews as the model streams tool arguments:
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
app.onToolInputPartial((params) => {
|
||||
try {
|
||||
const partial = JSON.parse(params.arguments);
|
||||
if (partial.query) {
|
||||
document.getElementById("preview").textContent = partial.query;
|
||||
}
|
||||
} catch {
|
||||
// partial JSON — ignore until parseable
|
||||
}
|
||||
});
|
||||
|
||||
app.onToolResult((params) => {
|
||||
const data = JSON.parse(params.result.content[0]?.text ?? "{}");
|
||||
renderResults(data);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### View State Persistence
|
||||
|
||||
Use `localStorage` to preserve UI state across re-renders. For important state, persist server-side via an app-only tool:
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
const STATE_KEY = "dashboard-view-state";
|
||||
|
||||
// Restore from localStorage
|
||||
const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
|
||||
if (saved.activeTab) selectTab(saved.activeTab);
|
||||
|
||||
// Save on interaction
|
||||
function saveState(state) {
|
||||
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
// For durable state, persist server-side
|
||||
async function saveServerState(state) {
|
||||
await app.callServerTool('save-dashboard-state', { state: JSON.stringify(state) });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Fullscreen Toggling
|
||||
|
||||
Switch between inline and fullscreen display modes and react to mode changes:
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
document.getElementById("expand-btn").addEventListener("click", () => {
|
||||
app.requestDisplayMode("fullscreen");
|
||||
});
|
||||
|
||||
app.onHostContextChanged((ctx) => {
|
||||
document.body.classList.toggle(
|
||||
"fullscreen",
|
||||
ctx.displayMode === "fullscreen",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Model Context Updates
|
||||
|
||||
Keep the model informed about what the user is viewing so it can provide relevant assistance:
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
async function notifyContext(view, detail) {
|
||||
await app.updateModelContext({
|
||||
currentView: view,
|
||||
detail: detail,
|
||||
});
|
||||
}
|
||||
|
||||
// Notify on tab change
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
notifyContext(tab.dataset.view, { filters: getActiveFilters() });
|
||||
});
|
||||
});
|
||||
|
||||
// For large payloads, follow up with sendMessage
|
||||
await app.updateModelContext({ currentView: "report", rows: 5000 });
|
||||
await app.sendMessage("The user is viewing a report with 5000 rows.");
|
||||
});
|
||||
```
|
||||
|
||||
### Pause Offscreen Views
|
||||
|
||||
Conserve resources by pausing animations and polling when the view is not visible:
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
let pollInterval = null;
|
||||
|
||||
function startPolling() {
|
||||
if (!pollInterval) {
|
||||
pollInterval = setInterval(fetchData, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
entry.isIntersecting ? startPolling() : stopPolling();
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement);
|
||||
startPolling();
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Return `Response::error()` from tools and use `updateModelContext()` to signal degraded state:
|
||||
|
||||
```php
|
||||
class ProcessData extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$request->validate(['input' => 'required|string']);
|
||||
|
||||
if (strlen($request->get('input')) > 10_000) {
|
||||
return Response::error('Input exceeds 10KB limit.');
|
||||
}
|
||||
|
||||
return Response::json(process($request->get('input')));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
createMcpApp(async (app) => {
|
||||
const result = await app.callServerTool('process-data', { input: value });
|
||||
|
||||
if (result.isError) {
|
||||
document.getElementById("error").textContent =
|
||||
result.content[0]?.text ?? "Unknown error";
|
||||
await app.updateModelContext({
|
||||
state: "error",
|
||||
message: result.content[0]?.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
renderOutput(JSON.parse(result.content[0]?.text ?? "{}"));
|
||||
});
|
||||
```
|
||||
539
vendor/laravel/mcp/resources/js/mcp-sdk.js
vendored
Normal file
539
vendor/laravel/mcp/resources/js/mcp-sdk.js
vendored
Normal file
@@ -0,0 +1,539 @@
|
||||
(function () {
|
||||
const jsonRpcVersion = "2.0";
|
||||
const protocolVersion = "2026-01-26";
|
||||
const queuedHandlerNames = [
|
||||
"ontoolinput",
|
||||
"ontoolinputpartial",
|
||||
"ontoolresult",
|
||||
"ontoolcancelled",
|
||||
"onhostcontextchanged",
|
||||
];
|
||||
|
||||
const errorCodes = {
|
||||
parseError: -32700,
|
||||
invalidRequest: -32600,
|
||||
methodNotFound: -32601,
|
||||
invalidParams: -32602,
|
||||
internalError: -32603,
|
||||
};
|
||||
|
||||
let nextRequestId = 0;
|
||||
|
||||
const pendingRequests = new Map();
|
||||
const handlers = {};
|
||||
const queuedNotifications = queuedHandlerNames.reduce(function (
|
||||
queue,
|
||||
name,
|
||||
) {
|
||||
queue[name] = [];
|
||||
|
||||
return queue;
|
||||
}, {});
|
||||
const state = {
|
||||
hostContext: null,
|
||||
hostInfo: null,
|
||||
hostCapabilities: null,
|
||||
};
|
||||
|
||||
let resizeObserver = null;
|
||||
|
||||
function disconnectResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
const notificationHandlers = {
|
||||
"ui/notifications/host-context-changed": applyHostContext,
|
||||
"ui/notifications/tool-input": function (params) {
|
||||
emit("ontoolinput", params ?? {});
|
||||
},
|
||||
"ui/notifications/tool-input-partial": function (params) {
|
||||
emit("ontoolinputpartial", params ?? {});
|
||||
},
|
||||
"ui/notifications/tool-result": function (params) {
|
||||
emit("ontoolresult", params ?? {});
|
||||
},
|
||||
"ui/notifications/tool-cancelled": function (params) {
|
||||
emit("ontoolcancelled", params ?? {});
|
||||
},
|
||||
};
|
||||
|
||||
function send(message) {
|
||||
message.jsonrpc = jsonRpcVersion;
|
||||
|
||||
window.parent.postMessage(message, "*");
|
||||
}
|
||||
|
||||
function request(method, params) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const id = ++nextRequestId;
|
||||
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
send({ id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
function notify(method, params) {
|
||||
const message = { method };
|
||||
|
||||
if (params !== undefined) {
|
||||
message.params = params;
|
||||
}
|
||||
|
||||
send(message);
|
||||
}
|
||||
|
||||
function respond(id, result) {
|
||||
send({ id, result });
|
||||
}
|
||||
|
||||
function respondWithError(id, code, message) {
|
||||
send({ id, error: { code, message } });
|
||||
}
|
||||
|
||||
function parseMessage(data) {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
return data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return (
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeParams(value, key) {
|
||||
return isObject(value) ? value : { [key]: value };
|
||||
}
|
||||
|
||||
function mergeObjects(original, toMerge) {
|
||||
return Object.assign({}, original || {}, toMerge || {});
|
||||
}
|
||||
|
||||
function mergeHostContext(update) {
|
||||
if (!update) {
|
||||
return state.hostContext;
|
||||
}
|
||||
|
||||
const current = state.hostContext || {};
|
||||
const next = mergeObjects(current, update);
|
||||
|
||||
if (current.styles || update.styles) {
|
||||
const currentStyles = current.styles || {};
|
||||
const nextStyles = update.styles || {};
|
||||
|
||||
next.styles = mergeObjects(currentStyles, nextStyles);
|
||||
next.styles.variables = mergeObjects(
|
||||
currentStyles.variables,
|
||||
nextStyles.variables,
|
||||
);
|
||||
next.styles.css = mergeObjects(currentStyles.css, nextStyles.css);
|
||||
}
|
||||
|
||||
state.hostContext = next;
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function flushQueuedNotifications(name) {
|
||||
const callback = handlers[name];
|
||||
const queue = queuedNotifications[name];
|
||||
|
||||
if (!callback || !queue || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
callback(queue.shift());
|
||||
}
|
||||
}
|
||||
|
||||
function emit(name, payload) {
|
||||
const callback = handlers[name];
|
||||
|
||||
if (callback) {
|
||||
callback(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (queuedNotifications[name]) {
|
||||
queuedNotifications[name].push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function setHandler(name, callback) {
|
||||
handlers[name] = callback;
|
||||
flushQueuedNotifications(name);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (!theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
function applyStyleVariables(variables) {
|
||||
if (!variables) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(variables)
|
||||
.filter((key) => variables[key] !== undefined)
|
||||
.forEach(function (key) {
|
||||
document.documentElement.style.setProperty(key, variables[key]);
|
||||
});
|
||||
}
|
||||
|
||||
function applyFonts(fontCss) {
|
||||
if (!fontCss) {
|
||||
return;
|
||||
}
|
||||
|
||||
let style = document.getElementById("__mcp-host-fonts");
|
||||
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = "__mcp-host-fonts";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
style.textContent = fontCss;
|
||||
}
|
||||
|
||||
function applyHostContext(update) {
|
||||
const hostContext = mergeHostContext(update);
|
||||
|
||||
if (!hostContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTheme(hostContext.theme);
|
||||
applyStyleVariables(hostContext.styles?.variables);
|
||||
applyFonts(hostContext.styles?.css?.fonts);
|
||||
emit("onhostcontextchanged", hostContext);
|
||||
}
|
||||
|
||||
function currentSize() {
|
||||
return {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function notifySizeChanged() {
|
||||
notify("ui/notifications/size-changed", currentSize());
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
if (typeof ResizeObserver === "undefined" || !document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectResizeObserver();
|
||||
|
||||
resizeObserver = new ResizeObserver(notifySizeChanged);
|
||||
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
return disconnectResizeObserver;
|
||||
}
|
||||
|
||||
async function handleTeardown(id) {
|
||||
try {
|
||||
disconnectResizeObserver();
|
||||
|
||||
respond(id, await (handlers.onteardown?.() ?? {}));
|
||||
} catch (error) {
|
||||
respondWithError(
|
||||
id,
|
||||
errorCodes.internalError,
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown teardown error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCallTool(id, params) {
|
||||
if (!handlers.oncalltool) {
|
||||
respondWithError(
|
||||
id,
|
||||
errorCodes.methodNotFound,
|
||||
"No tool handler registered.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
respond(id, await handlers.oncalltool(params));
|
||||
} catch (error) {
|
||||
respondWithError(
|
||||
id,
|
||||
errorCodes.internalError,
|
||||
error instanceof Error ? error.message : "Unknown tool error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListTools(id, params) {
|
||||
try {
|
||||
respond(
|
||||
id,
|
||||
await (handlers.onlisttools?.(params) ?? { tools: [] }),
|
||||
);
|
||||
} catch (error) {
|
||||
respondWithError(
|
||||
id,
|
||||
errorCodes.internalError,
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown list tools error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePendingResponse(message) {
|
||||
if (message.id === undefined || !pendingRequests.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(message.id);
|
||||
|
||||
pendingRequests.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleNotification(message) {
|
||||
const handler = notificationHandlers[message.method];
|
||||
|
||||
if (handler) {
|
||||
handler(message.params);
|
||||
}
|
||||
}
|
||||
|
||||
const requestHandlers = {
|
||||
"ui/resource-teardown": function (message) {
|
||||
handleTeardown(message.id);
|
||||
},
|
||||
"tools/call": function (message) {
|
||||
handleCallTool(message.id, message.params);
|
||||
},
|
||||
"tools/list": function (message) {
|
||||
handleListTools(message.id, message.params);
|
||||
},
|
||||
};
|
||||
|
||||
function handleIncomingRequest(message) {
|
||||
const handler = requestHandlers[message.method];
|
||||
|
||||
if (handler) {
|
||||
handler(message);
|
||||
} else {
|
||||
respondWithError(
|
||||
message.id,
|
||||
errorCodes.methodNotFound,
|
||||
"Method not found: " + message.method,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
if (event.source !== window.parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = parseMessage(event.data);
|
||||
|
||||
if (!message || message.jsonrpc !== jsonRpcVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlePendingResponse(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id === undefined) {
|
||||
handleNotification(message);
|
||||
return;
|
||||
}
|
||||
|
||||
handleIncomingRequest(message);
|
||||
});
|
||||
|
||||
window.createMcpApp = async function createMcpApp(setup) {
|
||||
const initializeResult = await request("ui/initialize", {
|
||||
protocolVersion: protocolVersion,
|
||||
appInfo: {
|
||||
name: document.title || "MCP App",
|
||||
version: "1.0.0",
|
||||
},
|
||||
appCapabilities: {},
|
||||
});
|
||||
|
||||
state.hostInfo = initializeResult?.hostInfo ?? null;
|
||||
state.hostCapabilities = initializeResult?.hostCapabilities ?? null;
|
||||
applyHostContext(initializeResult?.hostContext ?? null);
|
||||
|
||||
notify("ui/notifications/initialized");
|
||||
|
||||
function callServerTool(nameOrParams, args) {
|
||||
const params = isObject(nameOrParams)
|
||||
? {
|
||||
name: nameOrParams.name,
|
||||
arguments: nameOrParams.arguments || {},
|
||||
}
|
||||
: {
|
||||
name: nameOrParams,
|
||||
arguments: args || {},
|
||||
};
|
||||
|
||||
return request("tools/call", params);
|
||||
}
|
||||
|
||||
function listResources(cursorOrParams) {
|
||||
return request(
|
||||
"resources/list",
|
||||
cursorOrParams
|
||||
? normalizeParams(cursorOrParams, "cursor")
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function readResource(uriOrParams) {
|
||||
return request("resources/read", normalizeParams(uriOrParams, "uri"));
|
||||
}
|
||||
|
||||
function sendMessage(messageOrContent, role) {
|
||||
const params =
|
||||
isObject(messageOrContent) &&
|
||||
("content" in messageOrContent || "role" in messageOrContent)
|
||||
? {
|
||||
role: messageOrContent.role || "user",
|
||||
content: messageOrContent.content,
|
||||
}
|
||||
: {
|
||||
role: role || "user",
|
||||
content: messageOrContent,
|
||||
};
|
||||
|
||||
return request("ui/message", params);
|
||||
}
|
||||
|
||||
function openLink(urlOrParams) {
|
||||
return request("ui/open-link", normalizeParams(urlOrParams, "url"));
|
||||
}
|
||||
|
||||
function downloadFile(contentsOrParams) {
|
||||
const params =
|
||||
isObject(contentsOrParams) && "contents" in contentsOrParams
|
||||
? contentsOrParams
|
||||
: { contents: contentsOrParams };
|
||||
|
||||
return request("ui/download-file", params);
|
||||
}
|
||||
|
||||
function requestDisplayMode(modeOrParams) {
|
||||
return request(
|
||||
"ui/request-display-mode",
|
||||
normalizeParams(modeOrParams, "mode"),
|
||||
);
|
||||
}
|
||||
|
||||
function updateModelContext(params) {
|
||||
return request("ui/update-model-context", params || {});
|
||||
}
|
||||
|
||||
function requestTeardown() {
|
||||
notify("ui/notifications/request-teardown");
|
||||
}
|
||||
|
||||
function sendLog(levelOrParams, data, logger) {
|
||||
const params = isObject(levelOrParams)
|
||||
? levelOrParams
|
||||
: {
|
||||
level: levelOrParams,
|
||||
data: data,
|
||||
};
|
||||
|
||||
if (!isObject(levelOrParams) && logger !== undefined) {
|
||||
params.logger = logger;
|
||||
}
|
||||
|
||||
notify("notifications/message", params);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
await setup({
|
||||
getHostContext: function () {
|
||||
return state.hostContext;
|
||||
},
|
||||
getHostInfo: function () {
|
||||
return state.hostInfo;
|
||||
},
|
||||
getHostCapabilities: function () {
|
||||
return state.hostCapabilities;
|
||||
},
|
||||
callServerTool: callServerTool,
|
||||
listResources: listResources,
|
||||
readResource: readResource,
|
||||
sendMessage: sendMessage,
|
||||
openLink: openLink,
|
||||
downloadFile: downloadFile,
|
||||
requestDisplayMode: requestDisplayMode,
|
||||
updateModelContext: updateModelContext,
|
||||
requestTeardown: requestTeardown,
|
||||
sendLog: sendLog,
|
||||
resize: notifySizeChanged,
|
||||
autoResize: autoResize,
|
||||
onTeardown: function (callback) {
|
||||
handlers.onteardown = callback;
|
||||
},
|
||||
onCallTool: function (callback) {
|
||||
handlers.oncalltool = callback;
|
||||
},
|
||||
onListTools: function (callback) {
|
||||
handlers.onlisttools = callback;
|
||||
},
|
||||
onToolInput: function (callback) {
|
||||
setHandler("ontoolinput", callback);
|
||||
},
|
||||
onToolInputPartial: function (callback) {
|
||||
setHandler("ontoolinputpartial", callback);
|
||||
},
|
||||
onToolResult: function (callback) {
|
||||
setHandler("ontoolresult", callback);
|
||||
},
|
||||
onToolCancelled: function (callback) {
|
||||
setHandler("ontoolcancelled", callback);
|
||||
},
|
||||
onHostContextChanged: function (callback) {
|
||||
setHandler("onhostcontextchanged", callback);
|
||||
},
|
||||
});
|
||||
};
|
||||
})();
|
||||
1
vendor/laravel/mcp/resources/js/mcp-sdk.min.js
vendored
Normal file
1
vendor/laravel/mcp/resources/js/mcp-sdk.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
180
vendor/laravel/mcp/resources/views/mcp/authorize.blade.php
vendored
Normal file
180
vendor/laravel/mcp/resources/views/mcp/authorize.blade.php
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
<script>
|
||||
(function() {
|
||||
const appearance = '{{ $appearance ?? "system" }}';
|
||||
|
||||
if (appearance === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: oklch(0.145 0 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>Authorize Application - {{ config('app.name', 'MCP Server') }}</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Authorize MCP" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-background text-foreground">
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Card Container -->
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<!-- Shield Icon -->
|
||||
<svg class="h-12 w-12 text-primary" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.031 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold leading-none tracking-tight text-center">
|
||||
Authorize {{ $client->name }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
This application will be able to:<br/>Use available MCP functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 pt-0 space-y-4">
|
||||
<!-- User Info -->
|
||||
<div class="rounded-lg border p-4 bg-muted/50">
|
||||
<p class="text-sm text-muted-foreground mb-2">Logged in as:</p>
|
||||
<p class="font-medium">{{ $user->email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Scopes / Permissions -->
|
||||
@if(count($scopes) > 0)
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Permissions:</p>
|
||||
|
||||
<ul class="space-y-2">
|
||||
@foreach($scopes as $scope)
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $scope->description }}
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Footer With Buttons -->
|
||||
<div class="flex items-center p-6 pt-0 gap-3">
|
||||
<!-- Deny Form -->
|
||||
<form method="POST" action="{{ route('passport.authorizations.deny') }}" class="flex-1">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="state" value="">
|
||||
<input type="hidden" name="client_id" value="{{ $client->id }}">
|
||||
<input type="hidden" name="auth_token" value="{{ $authToken }}">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-full">
|
||||
<svg class="mr-2 h-4 w-4" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Approve Form -->
|
||||
<form method="POST" action="{{ route('passport.authorizations.approve') }}" class="flex-1" id="authorizeForm">
|
||||
@csrf
|
||||
<input type="hidden" name="state" value="">
|
||||
<input type="hidden" name="client_id" value="{{ $client->id }}">
|
||||
<input type="hidden" name="auth_token" value="{{ $authToken }}">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full" id="authorizeButton">
|
||||
<span id="authorizeText">Authorize</span>
|
||||
|
||||
<svg id="loadingSpinner" class="animate-spin -ml-1 mr-3 h-4 w-4 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('authorizeForm');
|
||||
const button = document.getElementById('authorizeButton');
|
||||
const authorizeText = document.getElementById('authorizeText');
|
||||
const loadingSpinner = document.getElementById('loadingSpinner');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Show loading state...
|
||||
button.disabled = true;
|
||||
authorizeText.textContent = 'Authorizing...';
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
|
||||
// After form submission, watch for redirect and close window...
|
||||
setTimeout(function() {
|
||||
const checkRedirect = setInterval(function() {
|
||||
// If URL changed or we have OAuth params, redirect happened...
|
||||
if (!window.location.href.includes('/oauth/authorize') ||
|
||||
window.location.search.includes('code=') ||
|
||||
window.location.search.includes('error=')) {
|
||||
clearInterval(checkRedirect);
|
||||
window.close();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Fallback: Close after five seconds...
|
||||
setTimeout(function() {
|
||||
clearInterval(checkRedirect);
|
||||
window.close();
|
||||
}, 5000);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Handle cancel button...
|
||||
const cancelForm = document.querySelector('form[method="POST"]:has(input[name="_method"][value="DELETE"])');
|
||||
if (cancelForm) {
|
||||
cancelForm.addEventListener('submit', function(e) {
|
||||
setTimeout(function() {
|
||||
window.close();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
vendor/laravel/mcp/resources/views/mcp/components/app.blade.php
vendored
Normal file
21
vendor/laravel/mcp/resources/views/mcp/components/app.blade.php
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
@props(['title' => null])
|
||||
@php
|
||||
$mcpSdk = app('mcp.sdk');
|
||||
$libraryScripts = app()->bound('mcp.library_scripts') ? app('mcp.library_scripts') : '';
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@if($title)
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<script>{!! $mcpSdk !!}</script>
|
||||
{!! $libraryScripts !!}
|
||||
{{ $head ?? '' }}
|
||||
</head>
|
||||
<body {{ $attributes }}>
|
||||
{{ $slot }}
|
||||
</body>
|
||||
</html>
|
||||
5
vendor/laravel/mcp/routes/ai.php
vendored
Normal file
5
vendor/laravel/mcp/routes/ai.php
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Mcp\Facades\Mcp;
|
||||
|
||||
// Mcp::web('/mcp/demo', \App\Mcp\Servers\PublicServer::class);
|
||||
164
vendor/laravel/mcp/src/Console/Commands/InspectorCommand.php
vendored
Normal file
164
vendor/laravel/mcp/src/Console/Commands/InspectorCommand.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
108
vendor/laravel/mcp/src/Console/Commands/MakeAppResourceCommand.php
vendored
Normal file
108
vendor/laravel/mcp/src/Console/Commands/MakeAppResourceCommand.php
vendored
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
43
vendor/laravel/mcp/src/Console/Commands/MakePromptCommand.php
vendored
Normal file
43
vendor/laravel/mcp/src/Console/Commands/MakePromptCommand.php
vendored
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
43
vendor/laravel/mcp/src/Console/Commands/MakeResourceCommand.php
vendored
Normal file
43
vendor/laravel/mcp/src/Console/Commands/MakeResourceCommand.php
vendored
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
63
vendor/laravel/mcp/src/Console/Commands/MakeServerCommand.php
vendored
Normal file
63
vendor/laravel/mcp/src/Console/Commands/MakeServerCommand.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
67
vendor/laravel/mcp/src/Console/Commands/MakeToolCommand.php
vendored
Normal file
67
vendor/laravel/mcp/src/Console/Commands/MakeToolCommand.php
vendored
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
46
vendor/laravel/mcp/src/Console/Commands/StartCommand.php
vendored
Normal file
46
vendor/laravel/mcp/src/Console/Commands/StartCommand.php
vendored
Normal 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
11
vendor/laravel/mcp/src/Enums/Role.php
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Mcp\Enums;
|
||||
|
||||
enum Role: string
|
||||
{
|
||||
case Assistant = 'assistant';
|
||||
case User = 'user';
|
||||
}
|
||||
47
vendor/laravel/mcp/src/Events/SessionInitialized.php
vendored
Normal file
47
vendor/laravel/mcp/src/Events/SessionInitialized.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
15
vendor/laravel/mcp/src/Exceptions/NotImplementedException.php
vendored
Normal file
15
vendor/laravel/mcp/src/Exceptions/NotImplementedException.php
vendored
Normal 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
30
vendor/laravel/mcp/src/Facades/Mcp.php
vendored
Normal 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
146
vendor/laravel/mcp/src/Request.php
vendored
Normal 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
186
vendor/laravel/mcp/src/Response.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
88
vendor/laravel/mcp/src/ResponseFactory.php
vendored
Normal file
88
vendor/laravel/mcp/src/ResponseFactory.php
vendored
Normal 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
347
vendor/laravel/mcp/src/Server.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
12
vendor/laravel/mcp/src/Server/Annotations/Annotation.php
vendored
Normal file
12
vendor/laravel/mcp/src/Server/Annotations/Annotation.php
vendored
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
40
vendor/laravel/mcp/src/Server/Annotations/Audience.php
vendored
Normal file
40
vendor/laravel/mcp/src/Server/Annotations/Audience.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
28
vendor/laravel/mcp/src/Server/Annotations/LastModified.php
vendored
Normal file
28
vendor/laravel/mcp/src/Server/Annotations/LastModified.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
26
vendor/laravel/mcp/src/Server/Annotations/Priority.php
vendored
Normal file
26
vendor/laravel/mcp/src/Server/Annotations/Priority.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
64
vendor/laravel/mcp/src/Server/AppResource.php
vendored
Normal file
64
vendor/laravel/mcp/src/Server/AppResource.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
82
vendor/laravel/mcp/src/Server/Attributes/AppMeta.php
vendored
Normal file
82
vendor/laravel/mcp/src/Server/Attributes/AppMeta.php
vendored
Normal 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 ?? []);
|
||||
}
|
||||
}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/Description.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/Description.php
vendored
Normal 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 {}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/Instructions.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/Instructions.php
vendored
Normal 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 {}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/MimeType.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/MimeType.php
vendored
Normal 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 {}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/Name.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/Name.php
vendored
Normal 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 {}
|
||||
23
vendor/laravel/mcp/src/Server/Attributes/RendersApp.php
vendored
Normal file
23
vendor/laravel/mcp/src/Server/Attributes/RendersApp.php
vendored
Normal 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],
|
||||
) {
|
||||
//
|
||||
}
|
||||
}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/ServerAttribute.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/ServerAttribute.php
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Mcp\Server\Attributes;
|
||||
|
||||
abstract class ServerAttribute
|
||||
{
|
||||
public function __construct(public string $value) {}
|
||||
}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/Title.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/Title.php
vendored
Normal 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 {}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/Uri.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/Uri.php
vendored
Normal 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 {}
|
||||
10
vendor/laravel/mcp/src/Server/Attributes/Version.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Attributes/Version.php
vendored
Normal 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 {}
|
||||
27
vendor/laravel/mcp/src/Server/Completions/ArrayCompletionResponse.php
vendored
Normal file
27
vendor/laravel/mcp/src/Server/Completions/ArrayCompletionResponse.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
28
vendor/laravel/mcp/src/Server/Completions/CompletionHelper.php
vendored
Normal file
28
vendor/laravel/mcp/src/Server/Completions/CompletionHelper.php
vendored
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
90
vendor/laravel/mcp/src/Server/Completions/CompletionResponse.php
vendored
Normal file
90
vendor/laravel/mcp/src/Server/Completions/CompletionResponse.php
vendored
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
13
vendor/laravel/mcp/src/Server/Completions/DirectCompletionResponse.php
vendored
Normal file
13
vendor/laravel/mcp/src/Server/Completions/DirectCompletionResponse.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
40
vendor/laravel/mcp/src/Server/Completions/EnumCompletionResponse.php
vendored
Normal file
40
vendor/laravel/mcp/src/Server/Completions/EnumCompletionResponse.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
70
vendor/laravel/mcp/src/Server/Concerns/HasAnnotations.php
vendored
Normal file
70
vendor/laravel/mcp/src/Server/Concerns/HasAnnotations.php
vendored
Normal 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 [];
|
||||
}
|
||||
}
|
||||
48
vendor/laravel/mcp/src/Server/Concerns/HasMeta.php
vendored
Normal file
48
vendor/laravel/mcp/src/Server/Concerns/HasMeta.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
36
vendor/laravel/mcp/src/Server/Concerns/HasStructuredContent.php
vendored
Normal file
36
vendor/laravel/mcp/src/Server/Concerns/HasStructuredContent.php
vendored
Normal 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]);
|
||||
}
|
||||
}
|
||||
42
vendor/laravel/mcp/src/Server/Concerns/ReadsAttributes.php
vendored
Normal file
42
vendor/laravel/mcp/src/Server/Concerns/ReadsAttributes.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
66
vendor/laravel/mcp/src/Server/Content/Audio.php
vendored
Normal file
66
vendor/laravel/mcp/src/Server/Content/Audio.php
vendored
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
vendor/laravel/mcp/src/Server/Content/Blob.php
vendored
Normal file
70
vendor/laravel/mcp/src/Server/Content/Blob.php
vendored
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
vendor/laravel/mcp/src/Server/Content/Image.php
vendored
Normal file
66
vendor/laravel/mcp/src/Server/Content/Image.php
vendored
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
vendor/laravel/mcp/src/Server/Content/Notification.php
vendored
Normal file
70
vendor/laravel/mcp/src/Server/Content/Notification.php
vendored
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
65
vendor/laravel/mcp/src/Server/Content/Text.php
vendored
Normal file
65
vendor/laravel/mcp/src/Server/Content/Text.php
vendored
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
10
vendor/laravel/mcp/src/Server/Contracts/Annotation.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Contracts/Annotation.php
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Mcp\Server\Contracts;
|
||||
|
||||
interface Annotation
|
||||
{
|
||||
public function key(): string;
|
||||
}
|
||||
15
vendor/laravel/mcp/src/Server/Contracts/Completable.php
vendored
Normal file
15
vendor/laravel/mcp/src/Server/Contracts/Completable.php
vendored
Normal 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;
|
||||
}
|
||||
39
vendor/laravel/mcp/src/Server/Contracts/Content.php
vendored
Normal file
39
vendor/laravel/mcp/src/Server/Contracts/Content.php
vendored
Normal 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;
|
||||
}
|
||||
10
vendor/laravel/mcp/src/Server/Contracts/Errable.php
vendored
Normal file
10
vendor/laravel/mcp/src/Server/Contracts/Errable.php
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Mcp\Server\Contracts;
|
||||
|
||||
interface Errable
|
||||
{
|
||||
//
|
||||
}
|
||||
15
vendor/laravel/mcp/src/Server/Contracts/HasUriTemplate.php
vendored
Normal file
15
vendor/laravel/mcp/src/Server/Contracts/HasUriTemplate.php
vendored
Normal 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;
|
||||
}
|
||||
20
vendor/laravel/mcp/src/Server/Contracts/Method.php
vendored
Normal file
20
vendor/laravel/mcp/src/Server/Contracts/Method.php
vendored
Normal 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;
|
||||
}
|
||||
20
vendor/laravel/mcp/src/Server/Contracts/Transport.php
vendored
Normal file
20
vendor/laravel/mcp/src/Server/Contracts/Transport.php
vendored
Normal 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;
|
||||
}
|
||||
33
vendor/laravel/mcp/src/Server/Exceptions/JsonRpcException.php
vendored
Normal file
33
vendor/laravel/mcp/src/Server/Exceptions/JsonRpcException.php
vendored
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
156
vendor/laravel/mcp/src/Server/Http/Controllers/OAuthRegisterController.php
vendored
Normal file
156
vendor/laravel/mcp/src/Server/Http/Controllers/OAuthRegisterController.php
vendored
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
120
vendor/laravel/mcp/src/Server/McpServiceProvider.php
vendored
Normal file
120
vendor/laravel/mcp/src/Server/McpServiceProvider.php
vendored
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
79
vendor/laravel/mcp/src/Server/Methods/CallTool.php
vendored
Normal file
79
vendor/laravel/mcp/src/Server/Methods/CallTool.php
vendored
Normal 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()),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
115
vendor/laravel/mcp/src/Server/Methods/CompletionComplete.php
vendored
Normal file
115
vendor/laravel/mcp/src/Server/Methods/CompletionComplete.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
127
vendor/laravel/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php
vendored
Normal file
127
vendor/laravel/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
24
vendor/laravel/mcp/src/Server/Methods/Concerns/ResolvesPrompts.php
vendored
Normal file
24
vendor/laravel/mcp/src/Server/Methods/Concerns/ResolvesPrompts.php
vendored
Normal 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.")
|
||||
);
|
||||
}
|
||||
}
|
||||
29
vendor/laravel/mcp/src/Server/Methods/Concerns/ResolvesResources.php
vendored
Normal file
29
vendor/laravel/mcp/src/Server/Methods/Concerns/ResolvesResources.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
64
vendor/laravel/mcp/src/Server/Methods/GetPrompt.php
vendored
Normal file
64
vendor/laravel/mcp/src/Server/Methods/GetPrompt.php
vendored
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
vendor/laravel/mcp/src/Server/Methods/Initialize.php
vendored
Normal file
48
vendor/laravel/mcp/src/Server/Methods/Initialize.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
25
vendor/laravel/mcp/src/Server/Methods/ListPrompts.php
vendored
Normal file
25
vendor/laravel/mcp/src/Server/Methods/ListPrompts.php
vendored
Normal 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'));
|
||||
}
|
||||
}
|
||||
25
vendor/laravel/mcp/src/Server/Methods/ListResourceTemplates.php
vendored
Normal file
25
vendor/laravel/mcp/src/Server/Methods/ListResourceTemplates.php
vendored
Normal 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'));
|
||||
}
|
||||
}
|
||||
25
vendor/laravel/mcp/src/Server/Methods/ListResources.php
vendored
Normal file
25
vendor/laravel/mcp/src/Server/Methods/ListResources.php
vendored
Normal 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'));
|
||||
}
|
||||
}
|
||||
25
vendor/laravel/mcp/src/Server/Methods/ListTools.php
vendored
Normal file
25
vendor/laravel/mcp/src/Server/Methods/ListTools.php
vendored
Normal 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'));
|
||||
}
|
||||
}
|
||||
18
vendor/laravel/mcp/src/Server/Methods/Ping.php
vendored
Normal file
18
vendor/laravel/mcp/src/Server/Methods/Ping.php
vendored
Normal 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, []);
|
||||
}
|
||||
}
|
||||
110
vendor/laravel/mcp/src/Server/Methods/ReadResource.php
vendored
Normal file
110
vendor/laravel/mcp/src/Server/Methods/ReadResource.php
vendored
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
vendor/laravel/mcp/src/Server/Middleware/AddWwwAuthenticateHeader.php
vendored
Normal file
43
vendor/laravel/mcp/src/Server/Middleware/AddWwwAuthenticateHeader.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
34
vendor/laravel/mcp/src/Server/Middleware/ReorderJsonAccept.php
vendored
Normal file
34
vendor/laravel/mcp/src/Server/Middleware/ReorderJsonAccept.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
73
vendor/laravel/mcp/src/Server/Pagination/CursorPaginator.php
vendored
Normal file
73
vendor/laravel/mcp/src/Server/Pagination/CursorPaginator.php
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
83
vendor/laravel/mcp/src/Server/Primitive.php
vendored
Normal file
83
vendor/laravel/mcp/src/Server/Primitive.php
vendored
Normal 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;
|
||||
}
|
||||
46
vendor/laravel/mcp/src/Server/Prompt.php
vendored
Normal file
46
vendor/laravel/mcp/src/Server/Prompt.php
vendored
Normal 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(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
vendor/laravel/mcp/src/Server/Prompts/Argument.php
vendored
Normal file
33
vendor/laravel/mcp/src/Server/Prompts/Argument.php
vendored
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
191
vendor/laravel/mcp/src/Server/Registrar.php
vendored
Normal file
191
vendor/laravel/mcp/src/Server/Registrar.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
99
vendor/laravel/mcp/src/Server/Resource.php
vendored
Normal file
99
vendor/laravel/mcp/src/Server/Resource.php
vendored
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
109
vendor/laravel/mcp/src/Server/ServerContext.php
vendored
Normal file
109
vendor/laravel/mcp/src/Server/ServerContext.php
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
175
vendor/laravel/mcp/src/Server/Testing/PendingTestResponse.php
vendored
Normal file
175
vendor/laravel/mcp/src/Server/Testing/PendingTestResponse.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
375
vendor/laravel/mcp/src/Server/Testing/TestResponse.php
vendored
Normal file
375
vendor/laravel/mcp/src/Server/Testing/TestResponse.php
vendored
Normal 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
109
vendor/laravel/mcp/src/Server/Tool.php
vendored
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsDestructive.php
vendored
Normal file
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsDestructive.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsIdempotent.php
vendored
Normal file
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsIdempotent.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsOpenWorld.php
vendored
Normal file
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsOpenWorld.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsReadOnly.php
vendored
Normal file
21
vendor/laravel/mcp/src/Server/Tools/Annotations/IsReadOnly.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
12
vendor/laravel/mcp/src/Server/Tools/Annotations/ToolAnnotation.php
vendored
Normal file
12
vendor/laravel/mcp/src/Server/Tools/Annotations/ToolAnnotation.php
vendored
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
39
vendor/laravel/mcp/src/Server/Transport/FakeTransporter.php
vendored
Normal file
39
vendor/laravel/mcp/src/Server/Transport/FakeTransporter.php
vendored
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
126
vendor/laravel/mcp/src/Server/Transport/HttpTransport.php
vendored
Normal file
126
vendor/laravel/mcp/src/Server/Transport/HttpTransport.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
41
vendor/laravel/mcp/src/Server/Transport/JsonRpcNotification.php
vendored
Normal file
41
vendor/laravel/mcp/src/Server/Transport/JsonRpcNotification.php
vendored
Normal 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'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user